Added support for the new agent mechanism and Binary

Added bullMQ + redis to the platform for automation and queue mechanism
Added new tabs in host details
This commit is contained in:
Muhammad Ibrahim
2025-10-15 20:56:58 +01:00
parent fdd0cfd619
commit 9a40d5e6ee
23 changed files with 1155 additions and 360 deletions

1
.gitignore vendored
View File

@@ -139,6 +139,7 @@ playwright-report/
test-results.xml
test_*.sh
test-*.sh
*.code-workspace
# Package manager lock files (uncomment if you want to ignore them)
# package-lock.json

View File

@@ -14,11 +14,12 @@
"db:studio": "prisma studio"
},
"dependencies": {
"@bull-board/api": "^6.13.0",
"@bull-board/express": "^6.13.0",
"@bull-board/api": "^6.13.1",
"@bull-board/express": "^6.13.1",
"@prisma/client": "^6.1.0",
"bcryptjs": "^2.4.3",
"bullmq": "^5.61.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@@ -31,7 +32,8 @@
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0",
"uuid": "^11.0.3",
"winston": "^3.17.0"
"winston": "^3.17.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",

View File

@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "job_history" (
"id" TEXT NOT NULL,
"job_id" TEXT NOT NULL,
"queue_name" TEXT NOT NULL,
"job_name" TEXT NOT NULL,
"host_id" TEXT,
"api_id" TEXT,
"status" TEXT NOT NULL,
"attempt_number" INTEGER NOT NULL DEFAULT 1,
"error_message" TEXT,
"output" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"completed_at" TIMESTAMP(3),
CONSTRAINT "job_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "job_history_job_id_idx" ON "job_history"("job_id");
-- CreateIndex
CREATE INDEX "job_history_queue_name_idx" ON "job_history"("queue_name");
-- CreateIndex
CREATE INDEX "job_history_host_id_idx" ON "job_history"("host_id");
-- CreateIndex
CREATE INDEX "job_history_api_id_idx" ON "job_history"("api_id");
-- CreateIndex
CREATE INDEX "job_history_status_idx" ON "job_history"("status");
-- CreateIndex
CREATE INDEX "job_history_created_at_idx" ON "job_history"("created_at");
-- AddForeignKey
ALTER TABLE "job_history" ADD CONSTRAINT "job_history_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -101,6 +101,7 @@ model hosts {
host_repositories host_repositories[]
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
update_history update_history[]
job_history job_history[]
@@index([machine_id])
@@index([friendly_name])
@@ -324,3 +325,27 @@ model docker_image_updates {
@@index([image_id])
@@index([is_security_update])
}
model job_history {
id String @id
job_id String
queue_name String
job_name String
host_id String?
api_id String?
status String
attempt_number Int @default(1)
error_message String?
output Json?
created_at DateTime @default(now())
updated_at DateTime
completed_at DateTime?
hosts hosts? @relation(fields: [host_id], references: [id], onDelete: SetNull)
@@index([job_id])
@@index([queue_name])
@@index([host_id])
@@index([api_id])
@@index([status])
@@index([created_at])
}

View File

@@ -1,11 +1,12 @@
const express = require("express");
const { queueManager, QUEUE_NAMES } = require("../services/automation");
const { getConnectedApiIds } = require("../services/agentWs");
const { authenticateToken } = require("../middleware/auth");
const router = express.Router();
// Get all queue statistics
router.get("/stats", authenticateToken, async (req, res) => {
router.get("/stats", authenticateToken, async (_req, res) => {
try {
const stats = await queueManager.getAllQueueStats();
res.json({
@@ -60,7 +61,10 @@ router.get("/jobs/:queueName", authenticateToken, async (req, res) => {
});
}
const jobs = await queueManager.getRecentJobs(queueName, parseInt(limit));
const jobs = await queueManager.getRecentJobs(
queueName,
parseInt(limit, 10),
);
// Format jobs for frontend
const formattedJobs = jobs.map((job) => ({
@@ -96,7 +100,7 @@ router.get("/jobs/:queueName", authenticateToken, async (req, res) => {
});
// Trigger manual GitHub update check
router.post("/trigger/github-update", authenticateToken, async (req, res) => {
router.post("/trigger/github-update", authenticateToken, async (_req, res) => {
try {
const job = await queueManager.triggerGitHubUpdateCheck();
res.json({
@@ -116,51 +120,61 @@ router.post("/trigger/github-update", authenticateToken, async (req, res) => {
});
// Trigger manual session cleanup
router.post("/trigger/session-cleanup", authenticateToken, async (req, res) => {
try {
const job = await queueManager.triggerSessionCleanup();
res.json({
success: true,
data: {
jobId: job.id,
message: "Session cleanup triggered successfully",
},
});
} catch (error) {
console.error("Error triggering session cleanup:", error);
res.status(500).json({
success: false,
error: "Failed to trigger session cleanup",
});
}
});
router.post(
"/trigger/session-cleanup",
authenticateToken,
async (_req, res) => {
try {
const job = await queueManager.triggerSessionCleanup();
res.json({
success: true,
data: {
jobId: job.id,
message: "Session cleanup triggered successfully",
},
});
} catch (error) {
console.error("Error triggering session cleanup:", error);
res.status(500).json({
success: false,
error: "Failed to trigger session cleanup",
});
}
},
);
// Trigger manual echo hello
router.post("/trigger/echo-hello", authenticateToken, async (req, res) => {
try {
const { message } = req.body;
const job = await queueManager.triggerEchoHello(message);
res.json({
success: true,
data: {
jobId: job.id,
message: "Echo hello triggered successfully",
},
});
} catch (error) {
console.error("Error triggering echo hello:", error);
res.status(500).json({
success: false,
error: "Failed to trigger echo hello",
});
}
});
// Trigger Agent Collection: enqueue report_now for connected agents only
router.post(
"/trigger/agent-collection",
authenticateToken,
async (_req, res) => {
try {
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
const apiIds = getConnectedApiIds();
if (!apiIds || apiIds.length === 0) {
return res.json({ success: true, data: { enqueued: 0 } });
}
const jobs = apiIds.map((apiId) => ({
name: "report_now",
data: { api_id: apiId, type: "report_now" },
opts: { attempts: 3, backoff: { type: "fixed", delay: 2000 } },
}));
await queue.addBulk(jobs);
res.json({ success: true, data: { enqueued: jobs.length } });
} catch (error) {
console.error("Error triggering agent collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to trigger agent collection" });
}
},
);
// Trigger manual orphaned repo cleanup
router.post(
"/trigger/orphaned-repo-cleanup",
authenticateToken,
async (req, res) => {
async (_req, res) => {
try {
const job = await queueManager.triggerOrphanedRepoCleanup();
res.json({
@@ -181,7 +195,7 @@ router.post(
);
// Get queue health status
router.get("/health", authenticateToken, async (req, res) => {
router.get("/health", authenticateToken, async (_req, res) => {
try {
const stats = await queueManager.getAllQueueStats();
const totalJobs = Object.values(stats).reduce((sum, queueStats) => {
@@ -224,7 +238,7 @@ router.get("/health", authenticateToken, async (req, res) => {
});
// Get automation overview (for dashboard cards)
router.get("/overview", authenticateToken, async (req, res) => {
router.get("/overview", authenticateToken, async (_req, res) => {
try {
const stats = await queueManager.getAllQueueStats();
@@ -232,7 +246,6 @@ router.get("/overview", authenticateToken, async (req, res) => {
const recentJobs = await Promise.all([
queueManager.getRecentJobs(QUEUE_NAMES.GITHUB_UPDATE_CHECK, 1),
queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ECHO_HELLO, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1),
]);
@@ -241,22 +254,16 @@ router.get("/overview", authenticateToken, async (req, res) => {
scheduledTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed +
stats[QUEUE_NAMES.SESSION_CLEANUP].delayed +
stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].delayed +
stats[QUEUE_NAMES.ECHO_HELLO].delayed +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed,
runningTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
stats[QUEUE_NAMES.SESSION_CLEANUP].active +
stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].active +
stats[QUEUE_NAMES.ECHO_HELLO].active +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active,
failedTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
stats[QUEUE_NAMES.SESSION_CLEANUP].failed +
stats[QUEUE_NAMES.SYSTEM_MAINTENANCE].failed +
stats[QUEUE_NAMES.ECHO_HELLO].failed +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed,
totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
@@ -305,10 +312,10 @@ router.get("/overview", authenticateToken, async (req, res) => {
stats: stats[QUEUE_NAMES.SESSION_CLEANUP],
},
{
name: "Echo Hello",
queue: QUEUE_NAMES.ECHO_HELLO,
description: "Simple test automation task",
schedule: "Manual only",
name: "Orphaned Repo Cleanup",
queue: QUEUE_NAMES.ORPHANED_REPO_CLEANUP,
description: "Removes repositories with no associated hosts",
schedule: "Daily at 2 AM",
lastRun: recentJobs[2][0]?.finishedOn
? new Date(recentJobs[2][0].finishedOn).toLocaleString()
: "Never",
@@ -318,22 +325,6 @@ router.get("/overview", authenticateToken, async (req, res) => {
: recentJobs[2][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.ECHO_HELLO],
},
{
name: "Orphaned Repo Cleanup",
queue: QUEUE_NAMES.ORPHANED_REPO_CLEANUP,
description: "Removes repositories with no associated hosts",
schedule: "Daily at 2 AM",
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.ORPHANED_REPO_CLEANUP],
},
].sort((a, b) => {

View File

@@ -8,6 +8,7 @@ const {
requireViewPackages,
requireViewUsers,
} = require("../middleware/permissions");
const { queueManager } = require("../services/automation");
const router = express.Router();
const prisma = new PrismaClient();
@@ -413,6 +414,51 @@ router.get(
},
);
// Get agent queue status for a specific host
router.get(
"/hosts/:hostId/queue",
authenticateToken,
requireViewHosts,
async (req, res) => {
try {
const { hostId } = req.params;
const { limit = 20 } = req.query;
// Get the host to find its API ID
const host = await prisma.hosts.findUnique({
where: { id: hostId },
select: { api_id: true, friendly_name: true },
});
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
// Get queue jobs for this host
const queueData = await queueManager.getHostJobs(
host.api_id,
parseInt(limit, 10),
);
res.json({
success: true,
data: {
hostId,
apiId: host.api_id,
friendlyName: host.friendly_name,
...queueData,
},
});
} catch (error) {
console.error("Error fetching host queue status:", error);
res.status(500).json({
success: false,
error: "Failed to fetch host queue status",
});
}
},
);
// Get recent users ordered by last_login desc
router.get(
"/recent-users",

View File

@@ -406,7 +406,7 @@ router.post(
// Process packages in batches using createMany/updateMany
const packagesToCreate = [];
const packagesToUpdate = [];
const hostPackagesToUpsert = [];
const _hostPackagesToUpsert = [];
// First pass: identify what needs to be created/updated
const existingPackages = await tx.packages.findMany({

View File

@@ -8,102 +8,9 @@ const { getSettings, updateSettings } = require("../services/settingsService");
const router = express.Router();
const prisma = new PrismaClient();
// Function to trigger crontab updates on all hosts with auto-update enabled
async function triggerCrontabUpdates() {
try {
console.log(
"Triggering crontab updates on all hosts with auto-update enabled...",
);
// Get current settings for server URL
const settings = await getSettings();
const serverUrl = settings.server_url;
// Get all hosts that have auto-update enabled
const hosts = await prisma.hosts.findMany({
where: {
auto_update: true,
status: "active", // Only update active hosts
},
select: {
id: true,
friendly_name: true,
api_id: true,
api_key: true,
},
});
console.log(`Found ${hosts.length} hosts with auto-update enabled`);
// For each host, we'll send a special update command that triggers crontab update
// This is done by sending a ping with a special flag
for (const host of hosts) {
try {
console.log(
`Triggering crontab update for host: ${host.friendly_name}`,
);
// We'll use the existing ping endpoint but add a special parameter
// The agent will detect this and run update-crontab command
const http = require("node:http");
const https = require("node:https");
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
const isHttps = url.protocol === "https:";
const client = isHttps ? https : http;
const postData = JSON.stringify({
triggerCrontabUpdate: true,
message: "Update interval changed, please update your crontab",
});
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(postData),
"X-API-ID": host.api_id,
"X-API-KEY": host.api_key,
},
};
const req = client.request(options, (res) => {
if (res.statusCode === 200) {
console.log(
`Successfully triggered crontab update for ${host.friendly_name}`,
);
} else {
console.error(
`Failed to trigger crontab update for ${host.friendly_name}: ${res.statusCode}`,
);
}
});
req.on("error", (error) => {
console.error(
`Error triggering crontab update for ${host.friendly_name}:`,
error.message,
);
});
req.write(postData);
req.end();
} catch (error) {
console.error(
`Error triggering crontab update for ${host.friendly_name}:`,
error.message,
);
}
}
console.log("Crontab update trigger completed");
} catch (error) {
console.error("Error in triggerCrontabUpdates:", error);
}
}
// WebSocket broadcaster for agent policy updates
const { broadcastSettingsUpdate } = require("../services/agentWs");
const { queueManager, QUEUE_NAMES } = require("../services/automation");
// Helpers
function normalizeUpdateInterval(minutes) {
@@ -290,15 +197,37 @@ router.put(
console.log("Settings updated successfully:", updatedSettings);
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
// If update interval changed, enqueue persistent jobs for agents
if (
updateInterval !== undefined &&
oldUpdateInterval !== updateData.update_interval
) {
console.log(
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Triggering crontab updates...`,
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Enqueueing agent settings updates...`,
);
await triggerCrontabUpdates();
const hosts = await prisma.hosts.findMany({
where: { status: "active" },
select: { api_id: true },
});
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
const jobs = hosts.map((h) => ({
name: "settings_update",
data: {
api_id: h.api_id,
type: "settings_update",
update_interval: updateData.update_interval,
},
opts: { attempts: 10, backoff: { type: "exponential", delay: 5000 } },
}));
// Bulk add jobs
await queue.addBulk(jobs);
// Also broadcast immediately to currently connected agents (best-effort)
// This ensures agents receive the change even if their host status isn't active yet
broadcastSettingsUpdate(updateData.update_interval);
}
res.json({

View File

@@ -39,6 +39,7 @@ const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const cookieParser = require("cookie-parser");
const {
createPrismaClient,
waitForDatabase,
@@ -69,6 +70,10 @@ const updateScheduler = require("./services/updateScheduler");
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");
const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter");
const { ExpressAdapter } = require("@bull-board/express");
// Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient();
@@ -255,6 +260,9 @@ if (process.env.ENABLE_LOGGING === "true") {
const app = express();
const PORT = process.env.PORT || 3001;
const http = require("node:http");
const server = http.createServer(app);
const { init: initAgentWs } = require("./services/agentWs");
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
if (process.env.TRUST_PROXY) {
@@ -342,12 +350,17 @@ app.use(
// Allow non-browser/SSR tools with no origin
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
// Allow same-origin requests (e.g., Bull Board accessing its own API)
// This allows http://hostname:3001 to make requests to http://hostname:3001
if (origin?.includes(":3001")) return callback(null, true);
return callback(new Error("Not allowed by CORS"));
},
credentials: true,
}),
);
app.use(limiter);
// Cookie parser for Bull Board sessions
app.use(cookieParser());
// Reduce body size limits to reasonable defaults
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "5mb" }));
app.use(
@@ -430,6 +443,122 @@ app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
app.use(`/api/${apiVersion}/automation`, automationRoutes);
app.use(`/api/${apiVersion}/docker`, dockerRoutes);
// Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null;
const bullBoardSessions = new Map(); // Store authenticated sessions
// Mount Bull Board at /admin instead of /api/v1/admin to avoid path conflicts
app.use(`/admin/queues`, (_req, res, next) => {
// Relax COOP/COEP for Bull Board in non-production to avoid browser warnings
if (process.env.NODE_ENV !== "production") {
res.setHeader("Cross-Origin-Opener-Policy", "same-origin-allow-popups");
res.setHeader("Cross-Origin-Embedder-Policy", "unsafe-none");
}
next();
});
// Authentication middleware for Bull Board
app.use(`/admin/queues`, async (req, res, next) => {
// Skip authentication for static assets only
if (req.path.includes("/static/") || req.path.includes("/favicon")) {
return next();
}
// Check for bull-board-session cookie first
const sessionId = req.cookies["bull-board-session"];
if (sessionId) {
const session = bullBoardSessions.get(sessionId);
if (session && Date.now() - session.timestamp < 3600000) {
// 1 hour
// Valid session, extend it
session.timestamp = Date.now();
return next();
} else if (session) {
// Expired session, remove it
bullBoardSessions.delete(sessionId);
}
}
// No valid session, check for token
let token = req.query.token;
if (!token && req.headers.authorization) {
token = req.headers.authorization.replace("Bearer ", "");
}
// If no token, deny access
if (!token) {
return res.status(401).json({ error: "Access token required" });
}
// Add token to headers for authentication
req.headers.authorization = `Bearer ${token}`;
// Authenticate the user
return authenticateToken(req, res, (err) => {
if (err) {
return res.status(401).json({ error: "Authentication failed" });
}
return requireAdmin(req, res, (adminErr) => {
if (adminErr) {
return res.status(403).json({ error: "Admin access required" });
}
// Authentication successful - create a session
const newSessionId = require("node:crypto")
.randomBytes(32)
.toString("hex");
bullBoardSessions.set(newSessionId, {
timestamp: Date.now(),
userId: req.user.id,
});
// Set session cookie
res.cookie("bull-board-session", newSessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 3600000, // 1 hour
});
// Clean up old sessions periodically
if (bullBoardSessions.size > 100) {
const now = Date.now();
for (const [sid, session] of bullBoardSessions.entries()) {
if (now - session.timestamp > 3600000) {
bullBoardSessions.delete(sid);
}
}
}
return next();
});
});
});
app.use(`/admin/queues`, (req, res, next) => {
if (bullBoardRouter) {
return bullBoardRouter(req, res, next);
}
return res.status(503).json({ error: "Bull Board not initialized yet" });
});
// Error handler specifically for Bull Board routes
app.use("/admin/queues", (err, req, res, _next) => {
console.error("Bull Board error on", req.method, req.url);
console.error("Error details:", err.message);
console.error("Stack:", err.stack);
if (process.env.ENABLE_LOGGING === "true") {
logger.error(`Bull Board error on ${req.method} ${req.url}:`, err);
}
res.status(500).json({
error: "Internal server error",
message: err.message,
path: req.path,
url: req.url,
});
});
// Error handling middleware
app.use((err, _req, res, _next) => {
if (process.env.ENABLE_LOGGING === "true") {
@@ -743,6 +872,25 @@ async function startServer() {
// Schedule recurring jobs
await queueManager.scheduleAllJobs();
// Set up Bull Board for queue monitoring
const serverAdapter = new ExpressAdapter();
// Set basePath to match where we mount the router
serverAdapter.setBasePath("/admin/queues");
const { QUEUE_NAMES } = require("./services/automation");
const bullAdapters = Object.values(QUEUE_NAMES).map(
(queueName) => new BullMQAdapter(queueManager.queues[queueName]),
);
createBullBoard({
queues: bullAdapters,
serverAdapter: serverAdapter,
});
// Set the router for the Bull Board middleware (secured middleware above)
bullBoardRouter = serverAdapter.getRouter();
console.log("✅ Bull Board mounted at /admin/queues (secured)");
// Initial session cleanup
await cleanup_expired_sessions();
@@ -758,7 +906,10 @@ async function startServer() {
60 * 60 * 1000,
); // Every hour
app.listen(PORT, () => {
// Initialize WS layer with the underlying HTTP server
initAgentWs(server, prisma);
server.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);

View File

@@ -0,0 +1,125 @@
// Lightweight WebSocket hub for agent connections
// Auth: X-API-ID / X-API-KEY headers on the upgrade request
const WebSocket = require("ws");
const url = require("node:url");
// Connection registry by api_id
const apiIdToSocket = new Map();
let wss;
let prisma;
function init(server, prismaClient) {
prisma = prismaClient;
wss = new WebSocket.Server({ noServer: true });
// Handle HTTP upgrade events and authenticate before accepting WS
server.on("upgrade", async (request, socket, head) => {
try {
const { pathname } = url.parse(request.url);
if (!pathname || !pathname.startsWith("/api/")) {
socket.destroy();
return;
}
// Expected path: /api/{v}/agents/ws
const parts = pathname.split("/").filter(Boolean); // [api, v1, agents, ws]
if (parts.length !== 4 || parts[2] !== "agents" || parts[3] !== "ws") {
socket.destroy();
return;
}
const apiId = request.headers["x-api-id"];
const apiKey = request.headers["x-api-key"];
if (!apiId || !apiKey) {
socket.destroy();
return;
}
// Validate credentials
const host = await prisma.hosts.findUnique({ where: { api_id: apiId } });
if (!host || host.api_key !== apiKey) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
ws.apiId = apiId;
apiIdToSocket.set(apiId, ws);
console.log(
`[agent-ws] connected api_id=${apiId} total=${apiIdToSocket.size}`,
);
ws.on("message", () => {
// Currently we don't need to handle agent->server messages
});
ws.on("close", () => {
const existing = apiIdToSocket.get(apiId);
if (existing === ws) {
apiIdToSocket.delete(apiId);
}
console.log(
`[agent-ws] disconnected api_id=${apiId} total=${apiIdToSocket.size}`,
);
});
// Optional: greet/ack
safeSend(ws, JSON.stringify({ type: "connected" }));
});
} catch (_err) {
try {
socket.destroy();
} catch {
/* ignore */
}
}
});
}
function safeSend(ws, data) {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(data);
} catch {
/* ignore */
}
}
}
function broadcastSettingsUpdate(newInterval) {
const payload = JSON.stringify({
type: "settings_update",
update_interval: newInterval,
});
for (const [, ws] of apiIdToSocket) {
safeSend(ws, payload);
}
}
function pushReportNow(apiId) {
const ws = apiIdToSocket.get(apiId);
safeSend(ws, JSON.stringify({ type: "report_now" }));
}
function pushSettingsUpdate(apiId, newInterval) {
const ws = apiIdToSocket.get(apiId);
safeSend(
ws,
JSON.stringify({ type: "settings_update", update_interval: newInterval }),
);
}
module.exports = {
init,
broadcastSettingsUpdate,
pushReportNow,
pushSettingsUpdate,
// Expose read-only view of connected agents
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
isConnected: (apiId) => {
const ws = apiIdToSocket.get(apiId);
return !!ws && ws.readyState === WebSocket.OPEN;
},
};

View File

@@ -1,67 +0,0 @@
/**
* Echo Hello Automation
* Simple test automation task
*/
class EchoHello {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "echo-hello";
}
/**
* Process echo hello job
*/
async process(job) {
const startTime = Date.now();
console.log("👋 Starting echo hello task...");
try {
// Simple echo task
const message = job.data.message || "Hello from BullMQ!";
const timestamp = new Date().toISOString();
// Simulate some work
await new Promise((resolve) => setTimeout(resolve, 100));
const executionTime = Date.now() - startTime;
console.log(`✅ Echo hello completed in ${executionTime}ms: ${message}`);
return {
success: true,
message,
timestamp,
executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ Echo hello failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Echo hello is manual only - no scheduling
*/
async schedule() {
console.log(" Echo hello is manual only - no scheduling needed");
return null;
}
/**
* Trigger manual echo hello
*/
async triggerManual(message = "Hello from BullMQ!") {
const job = await this.queueManager.queues[this.queueName].add(
"echo-hello-manual",
{ message },
{ priority: 1 },
);
console.log("✅ Manual echo hello triggered");
return job;
}
}
module.exports = EchoHello;

View File

@@ -14,7 +14,7 @@ class GitHubUpdateCheck {
/**
* Process GitHub update check job
*/
async process(job) {
async process(_job) {
const startTime = Date.now();
console.log("🔍 Starting GitHub update check...");

View File

@@ -1,20 +1,19 @@
const { Queue, Worker } = require("bullmq");
const { redis, redisConnection } = require("./shared/redis");
const { prisma } = require("./shared/prisma");
const agentWs = require("../agentWs");
// Import automation classes
const GitHubUpdateCheck = require("./githubUpdateCheck");
const SessionCleanup = require("./sessionCleanup");
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
const EchoHello = require("./echoHello");
// Queue names
const QUEUE_NAMES = {
GITHUB_UPDATE_CHECK: "github-update-check",
SESSION_CLEANUP: "session-cleanup",
SYSTEM_MAINTENANCE: "system-maintenance",
ECHO_HELLO: "echo-hello",
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
AGENT_COMMANDS: "agent-commands",
};
/**
@@ -60,7 +59,7 @@ class QueueManager {
* Initialize all queues
*/
async initializeQueues() {
for (const [key, queueName] of Object.entries(QUEUE_NAMES)) {
for (const [_key, queueName] of Object.entries(QUEUE_NAMES)) {
this.queues[queueName] = new Queue(queueName, {
connection: redisConnection,
defaultJobOptions: {
@@ -88,7 +87,6 @@ class QueueManager {
this.automations[QUEUE_NAMES.SESSION_CLEANUP] = new SessionCleanup(this);
this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP] =
new OrphanedRepoCleanup(this);
this.automations[QUEUE_NAMES.ECHO_HELLO] = new EchoHello(this);
console.log("✅ All automation classes initialized");
}
@@ -133,15 +131,150 @@ class QueueManager {
},
);
// Echo Hello Worker
this.workers[QUEUE_NAMES.ECHO_HELLO] = new Worker(
QUEUE_NAMES.ECHO_HELLO,
this.automations[QUEUE_NAMES.ECHO_HELLO].process.bind(
this.automations[QUEUE_NAMES.ECHO_HELLO],
),
// Agent Commands Worker
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
QUEUE_NAMES.AGENT_COMMANDS,
async (job) => {
const { api_id, type, update_interval } = job.data || {};
console.log("[agent-commands] processing job", job.id, api_id, type);
// Log job attempt to history - use job.id as the unique identifier
const attemptNumber = job.attemptsMade || 1;
const historyId = job.id; // Single row per job, updated with each attempt
try {
if (!api_id || !type) {
throw new Error("invalid job data");
}
// Find host by api_id
const host = await prisma.hosts.findUnique({
where: { api_id },
select: { id: true },
});
// Ensure agent is connected; if not, retry later
if (!agentWs.isConnected(api_id)) {
const error = new Error("agent not connected");
// Log failed attempt
await prisma.job_history.upsert({
where: { id: historyId },
create: {
id: historyId,
job_id: job.id,
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
job_name: type,
host_id: host?.id,
api_id,
status: "failed",
attempt_number: attemptNumber,
error_message: error.message,
created_at: new Date(),
updated_at: new Date(),
},
update: {
status: "failed",
attempt_number: attemptNumber,
error_message: error.message,
updated_at: new Date(),
},
});
console.log(
"[agent-commands] agent not connected, will retry",
api_id,
);
throw error;
}
// Process the command
let result;
if (type === "settings_update") {
agentWs.pushSettingsUpdate(api_id, update_interval);
console.log(
"[agent-commands] delivered settings_update",
api_id,
update_interval,
);
result = { delivered: true, update_interval };
} else if (type === "report_now") {
agentWs.pushReportNow(api_id);
console.log("[agent-commands] delivered report_now", api_id);
result = { delivered: true };
} else {
throw new Error("unsupported agent command");
}
// Log successful completion
await prisma.job_history.upsert({
where: { id: historyId },
create: {
id: historyId,
job_id: job.id,
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
job_name: type,
host_id: host?.id,
api_id,
status: "completed",
attempt_number: attemptNumber,
output: result,
created_at: new Date(),
updated_at: new Date(),
completed_at: new Date(),
},
update: {
status: "completed",
attempt_number: attemptNumber,
output: result,
error_message: null,
updated_at: new Date(),
completed_at: new Date(),
},
});
return result;
} catch (error) {
// Log error to history (if not already logged above)
if (error.message !== "agent not connected") {
const host = await prisma.hosts
.findUnique({
where: { api_id },
select: { id: true },
})
.catch(() => null);
await prisma.job_history
.upsert({
where: { id: historyId },
create: {
id: historyId,
job_id: job.id,
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
job_name: type || "unknown",
host_id: host?.id,
api_id,
status: "failed",
attempt_number: attemptNumber,
error_message: error.message,
created_at: new Date(),
updated_at: new Date(),
},
update: {
status: "failed",
attempt_number: attemptNumber,
error_message: error.message,
updated_at: new Date(),
},
})
.catch((err) =>
console.error("[agent-commands] failed to log error:", err),
);
}
throw error;
}
},
{
connection: redisConnection,
concurrency: 1,
concurrency: 10,
},
);
@@ -184,7 +317,6 @@ class QueueManager {
await this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].schedule();
await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ECHO_HELLO].schedule();
}
/**
@@ -202,10 +334,6 @@ class QueueManager {
return this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].triggerManual();
}
async triggerEchoHello(message = "Hello from BullMQ!") {
return this.automations[QUEUE_NAMES.ECHO_HELLO].triggerManual(message);
}
/**
* Get queue statistics
*/
@@ -262,6 +390,73 @@ class QueueManager {
.slice(0, limit);
}
/**
* Get jobs for a specific host (by API ID)
*/
async getHostJobs(apiId, limit = 20) {
const queue = this.queues[QUEUE_NAMES.AGENT_COMMANDS];
if (!queue) {
throw new Error(`Queue ${QUEUE_NAMES.AGENT_COMMANDS} not found`);
}
console.log(`[getHostJobs] Looking for jobs with api_id: ${apiId}`);
// Get active queue status (waiting, active, delayed, failed)
const [waiting, active, delayed, failed] = await Promise.all([
queue.getWaiting(),
queue.getActive(),
queue.getDelayed(),
queue.getFailed(),
]);
// Filter by API ID
const filterByApiId = (jobs) =>
jobs.filter((job) => job.data && job.data.api_id === apiId);
const waitingCount = filterByApiId(waiting).length;
const activeCount = filterByApiId(active).length;
const delayedCount = filterByApiId(delayed).length;
const failedCount = filterByApiId(failed).length;
console.log(
`[getHostJobs] Queue status - Waiting: ${waitingCount}, Active: ${activeCount}, Delayed: ${delayedCount}, Failed: ${failedCount}`,
);
// Get job history from database (shows all attempts and status changes)
const jobHistory = await prisma.job_history.findMany({
where: {
api_id: apiId,
},
orderBy: {
created_at: "desc",
},
take: limit,
});
console.log(
`[getHostJobs] Found ${jobHistory.length} job history records for api_id: ${apiId}`,
);
return {
waiting: waitingCount,
active: activeCount,
delayed: delayedCount,
failed: failedCount,
jobHistory: jobHistory.map((job) => ({
id: job.id,
job_id: job.job_id,
job_name: job.job_name,
status: job.status,
attempt_number: job.attempt_number,
error_message: job.error_message,
output: job.output,
created_at: job.created_at,
updated_at: job.updated_at,
completed_at: job.completed_at,
})),
};
}
/**
* Graceful shutdown
*/
@@ -269,8 +464,24 @@ class QueueManager {
console.log("🛑 Shutting down queue manager...");
for (const queueName of Object.keys(this.queues)) {
await this.queues[queueName].close();
await this.workers[queueName].close();
try {
await this.queues[queueName].close();
} catch (e) {
console.warn(
`⚠️ Failed to close queue '${queueName}':`,
e?.message || e,
);
}
if (this.workers?.[queueName]) {
try {
await this.workers[queueName].close();
} catch (e) {
console.warn(
`⚠️ Failed to close worker for '${queueName}':`,
e?.message || e,
);
}
}
}
await redis.quit();

View File

@@ -13,7 +13,7 @@ class OrphanedRepoCleanup {
/**
* Process orphaned repository cleanup job
*/
async process(job) {
async process(_job) {
const startTime = Date.now();
console.log("🧹 Starting orphaned repository cleanup...");

View File

@@ -1,5 +1,4 @@
const { prisma } = require("./shared/prisma");
const { cleanup_expired_sessions } = require("../../utils/session_manager");
/**
* Session Cleanup Automation
@@ -14,7 +13,7 @@ class SessionCleanup {
/**
* Process session cleanup job
*/
async process(job) {
async process(_job) {
const startTime = Date.now();
console.log("🧹 Starting session cleanup...");

View File

@@ -3,9 +3,9 @@ const IORedis = require("ioredis");
// Redis connection configuration
const redisConnection = {
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT) || 6379,
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB) || 0,
db: parseInt(process.env.REDIS_DB, 10) || 0,
retryDelayOnFailover: 100,
maxRetriesPerRequest: null, // BullMQ requires this to be null
};

View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<circle fill="#DD2E44" cx="18" cy="18" r="18" />
<circle fill="#FFF" cx="18" cy="18" r="13.5" />
<circle fill="#DD2E44" cx="18" cy="18" r="10" />
<circle fill="#FFF" cx="18" cy="18" r="6" />
<circle fill="#DD2E44" cx="18" cy="18" r="3" />
<path
opacity=".2"
d="M18.24 18.282l13.144 11.754s-2.647 3.376-7.89 5.109L17.579 18.42l.661-.138z"
/>
<path
fill="#FFAC33"
d="M18.294 19a.994.994 0 01-.704-1.699l.563-.563a.995.995 0 011.408 1.407l-.564.563a.987.987 0 01-.703.292z"
/>
<path
fill="#55ACEE"
d="M24.016 6.981c-.403 2.079 0 4.691 0 4.691l7.054-7.388c.291-1.454-.528-3.932-1.718-4.238-1.19-.306-4.079.803-5.336 6.935zm5.003 5.003c-2.079.403-4.691 0-4.691 0l7.388-7.054c1.454-.291 3.932.528 4.238 1.718.306 1.19-.803 4.079-6.935 5.336z"
/>
<path
fill="#3A87C2"
d="M32.798 4.485L21.176 17.587c-.362.362-1.673.882-2.51.046-.836-.836-.419-2.08-.057-2.443L31.815 3.501s.676-.635 1.159-.152-.176 1.136-.176 1.136z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,20 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertCircle,
ArrowDown,
ArrowUp,
ArrowUpDown,
Bot,
CheckCircle,
Clock,
Play,
RefreshCw,
Settings,
XCircle,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import api from "../utils/api";
const Automation = () => {
@@ -33,7 +30,7 @@ const Automation = () => {
});
// Fetch queue statistics
const { data: queueStats, isLoading: statsLoading } = useQuery({
useQuery({
queryKey: ["automation-stats"],
queryFn: async () => {
const response = await api.get("/automation/stats");
@@ -43,7 +40,7 @@ const Automation = () => {
});
// Fetch recent jobs
const { data: recentJobs, isLoading: jobsLoading } = useQuery({
useQuery({
queryKey: ["automation-jobs"],
queryFn: async () => {
const jobs = await Promise.all([
@@ -62,7 +59,7 @@ const Automation = () => {
refetchInterval: 30000,
});
const getStatusIcon = (status) => {
const _getStatusIcon = (status) => {
switch (status) {
case "completed":
return <CheckCircle className="h-4 w-4 text-green-500" />;
@@ -75,7 +72,7 @@ const Automation = () => {
}
};
const getStatusColor = (status) => {
const _getStatusColor = (status) => {
switch (status) {
case "completed":
return "bg-green-100 text-green-800";
@@ -88,12 +85,12 @@ const Automation = () => {
}
};
const formatDate = (dateString) => {
const _formatDate = (dateString) => {
if (!dateString) return "N/A";
return new Date(dateString).toLocaleString();
};
const formatDuration = (ms) => {
const _formatDuration = (ms) => {
if (!ms) return "N/A";
return `${ms}ms`;
};
@@ -127,7 +124,7 @@ const Automation = () => {
}
};
const getNextRunTime = (schedule, lastRun) => {
const getNextRunTime = (schedule, _lastRun) => {
if (schedule === "Manual only") return "Manual trigger only";
if (schedule === "Daily at midnight") {
const now = new Date();
@@ -198,6 +195,19 @@ const Automation = () => {
return Number.MAX_SAFE_INTEGER; // Unknown schedules go to bottom
};
const openBullBoard = () => {
const token = localStorage.getItem("token");
if (!token) {
alert("Please log in to access the Queue Monitor");
return;
}
// Use the proxied URL through the frontend (port 3000)
// This avoids CORS issues as everything goes through the same origin
const url = `/admin/queues?token=${encodeURIComponent(token)}`;
window.open(url, "_blank", "width=1200,height=800");
};
const triggerManualJob = async (jobType, data = {}) => {
try {
let endpoint;
@@ -206,13 +216,11 @@ const Automation = () => {
endpoint = "/automation/trigger/github-update";
} else if (jobType === "sessions") {
endpoint = "/automation/trigger/session-cleanup";
} else if (jobType === "echo") {
endpoint = "/automation/trigger/echo-hello";
} else if (jobType === "orphaned-repos") {
endpoint = "/automation/trigger/orphaned-repo-cleanup";
}
const response = await api.post(endpoint, data);
const _response = await api.post(endpoint, data);
// Refresh data
window.location.reload();
@@ -303,34 +311,40 @@ const Automation = () => {
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => triggerManualJob("github")}
onClick={openBullBoard}
className="btn-outline flex items-center gap-2"
title="Trigger manual GitHub update check"
title="Open Bull Board Queue Monitor"
>
<RefreshCw className="h-4 w-4" />
Check Updates
</button>
<button
type="button"
onClick={() => triggerManualJob("sessions")}
className="btn-outline flex items-center gap-2"
title="Trigger manual session cleanup"
>
<RefreshCw className="h-4 w-4" />
Clean Sessions
</button>
<button
type="button"
onClick={() =>
triggerManualJob("echo", {
message: "Hello from Automation Page!",
})
}
className="btn-outline flex items-center gap-2"
title="Trigger echo hello task"
>
<RefreshCw className="h-4 w-4" />
Echo Hello
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 36 36"
role="img"
aria-label="Bull Board"
>
<circle fill="#DD2E44" cx="18" cy="18" r="18" />
<circle fill="#FFF" cx="18" cy="18" r="13.5" />
<circle fill="#DD2E44" cx="18" cy="18" r="10" />
<circle fill="#FFF" cx="18" cy="18" r="6" />
<circle fill="#DD2E44" cx="18" cy="18" r="3" />
<path
opacity=".2"
d="M18.24 18.282l13.144 11.754s-2.647 3.376-7.89 5.109L17.579 18.42l.661-.138z"
/>
<path
fill="#FFAC33"
d="M18.294 19a.994.994 0 01-.704-1.699l.563-.563a.995.995 0 011.408 1.407l-.564.563a.987.987 0 01-.703.292z"
/>
<path
fill="#55ACEE"
d="M24.016 6.981c-.403 2.079 0 4.691 0 4.691l7.054-7.388c.291-1.454-.528-3.932-1.718-4.238-1.19-.306-4.079.803-5.336 6.935zm5.003 5.003c-2.079.403-4.691 0-4.691 0l7.388-7.054c1.454-.291 3.932.528 4.238 1.718.306 1.19-.803 4.079-6.935 5.336z"
/>
<path
fill="#3A87C2"
d="M32.798 4.485L21.176 17.587c-.362.362-1.673.882-2.51.046-.836-.836-.419-2.08-.057-2.443L31.815 3.501s.676-.635 1.159-.152-.176 1.136-.176 1.136z"
/>
</svg>
Queue Monitor
</button>
</div>
</div>
@@ -509,10 +523,6 @@ const Automation = () => {
triggerManualJob("github");
} else if (automation.queue.includes("session")) {
triggerManualJob("sessions");
} else if (automation.queue.includes("echo")) {
triggerManualJob("echo", {
message: "Manual trigger from table",
});
} else if (
automation.queue.includes("orphaned-repo")
) {
@@ -525,20 +535,7 @@ const Automation = () => {
<Play className="h-3 w-3" />
</button>
) : (
<button
type="button"
onClick={() => {
if (automation.queue.includes("echo")) {
triggerManualJob("echo", {
message: "Manual trigger from table",
});
}
}}
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"
title="Trigger"
>
<Play className="h-3 w-3" />
</button>
<span className="text-gray-400 text-xs">Manual</span>
)}
</td>
<td className="px-4 py-2 whitespace-nowrap">

View File

@@ -1161,7 +1161,7 @@ const Dashboard = () => {
try {
const date = new Date(`${label}:00:00`);
// Check if date is valid
if (isNaN(date.getTime())) {
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
@@ -1171,7 +1171,7 @@ const Dashboard = () => {
minute: "2-digit",
hour12: true,
});
} catch (error) {
} catch (_error) {
return label; // Return original label if parsing fails
}
}
@@ -1180,14 +1180,14 @@ const Dashboard = () => {
try {
const date = new Date(label);
// Check if date is valid
if (isNaN(date.getTime())) {
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
} catch (error) {
} catch (_error) {
return label; // Return original label if parsing fails
}
},
@@ -1222,7 +1222,7 @@ const Dashboard = () => {
const hourNum = parseInt(hour, 10);
// Validate hour number
if (isNaN(hourNum) || hourNum < 0 || hourNum > 23) {
if (Number.isNaN(hourNum) || hourNum < 0 || hourNum > 23) {
return hour; // Return original hour if invalid
}
@@ -1233,7 +1233,7 @@ const Dashboard = () => {
: hourNum === 12
? "12 PM"
: `${hourNum - 12} PM`;
} catch (error) {
} catch (_error) {
return label; // Return original label if parsing fails
}
}
@@ -1242,14 +1242,14 @@ const Dashboard = () => {
try {
const date = new Date(label);
// Check if date is valid
if (isNaN(date.getTime())) {
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
} catch (error) {
} catch (_error) {
return label; // Return original label if parsing fails
}
},

View File

@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
AlertCircle,
AlertTriangle,
ArrowLeft,
Calendar,
CheckCircle,
CheckCircle2,
Clock,
Clock3,
Copy,
Cpu,
Database,
@@ -46,6 +49,7 @@ const HostDetail = () => {
const [activeTab, setActiveTab] = useState("host");
const [historyPage, setHistoryPage] = useState(0);
const [historyLimit] = useState(10);
const [notes, setNotes] = useState("");
const {
data: host,
@@ -87,6 +91,13 @@ const HostDetail = () => {
}
}, [host]);
// Sync notes state with host data
useEffect(() => {
if (host) {
setNotes(host.notes || "");
}
}, [host]);
const deleteHostMutation = useMutation({
mutationFn: (hostId) => adminHostsAPI.delete(hostId),
onSuccess: () => {
@@ -292,10 +303,10 @@ const HostDetail = () => {
<button
type="button"
onClick={() => setShowDeleteModal(true)}
className="btn-danger flex items-center gap-2 text-sm"
className="btn-danger flex items-center justify-center p-2 text-sm"
title="Delete host"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
</div>
@@ -426,7 +437,18 @@ const HostDetail = () => {
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Agent History
Package Reports
</button>
<button
type="button"
onClick={() => handleTabChange("queue")}
className={`px-4 py-2 text-sm font-medium ${
activeTab === "queue"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
}`}
>
Agent Queue
</button>
<button
type="button"
@@ -1097,12 +1119,8 @@ const HostDetail = () => {
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<textarea
value={host.notes || ""}
onChange={(e) => {
// Update local state immediately for better UX
const updatedHost = { ...host, notes: e.target.value };
queryClient.setQueryData(["host", hostId], updatedHost);
}}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes about this host... (e.g., purpose, special configurations, maintenance notes)"
className="w-full h-32 p-3 border border-secondary-200 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
maxLength={1000}
@@ -1114,14 +1132,14 @@ const HostDetail = () => {
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-secondary-400 dark:text-secondary-500">
{(host.notes || "").length}/1000
{notes.length}/1000
</span>
<button
type="button"
onClick={() => {
updateNotesMutation.mutate({
hostId: host.id,
notes: host.notes || "",
notes: notes,
});
}}
disabled={updateNotesMutation.isPending}
@@ -1136,6 +1154,9 @@ const HostDetail = () => {
</div>
</div>
)}
{/* Agent Queue */}
{activeTab === "queue" && <AgentQueueTab hostId={hostId} />}
</div>
</div>
</div>
@@ -1659,4 +1680,250 @@ const DeleteConfirmationModal = ({
);
};
// Agent Queue Tab Component
const AgentQueueTab = ({ hostId }) => {
const {
data: queueData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["host-queue", hostId],
queryFn: () => dashboardAPI.getHostQueue(hostId).then((res) => res.data),
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-32">
<RefreshCw className="h-6 w-6 animate-spin text-primary-600" />
</div>
);
}
if (error) {
return (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400">
Failed to load queue data
</p>
<button
type="button"
onClick={() => refetch()}
className="mt-2 px-4 py-2 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
>
Retry
</button>
</div>
);
}
const { waiting, active, delayed, failed, jobHistory } = queueData.data;
const getStatusIcon = (status) => {
switch (status) {
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <AlertCircle className="h-4 w-4 text-red-500" />;
case "active":
return <Clock3 className="h-4 w-4 text-blue-500" />;
default:
return <Clock className="h-4 w-4 text-gray-500" />;
}
};
const getStatusColor = (status) => {
switch (status) {
case "completed":
return "text-green-600 dark:text-green-400";
case "failed":
return "text-red-600 dark:text-red-400";
case "active":
return "text-blue-600 dark:text-blue-400";
default:
return "text-gray-600 dark:text-gray-400";
}
};
const formatJobType = (type) => {
switch (type) {
case "settings_update":
return "Settings Update";
case "report_now":
return "Report Now";
case "update_agent":
return "Agent Update";
default:
return type;
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Agent Queue Status
</h3>
<button
type="button"
onClick={() => refetch()}
className="btn-outline flex items-center gap-2"
title="Refresh queue data"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
{/* Queue Summary */}
<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="flex items-center gap-3">
<Server className="h-8 w-8 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm text-blue-600 dark:text-blue-400 font-medium">
Waiting
</p>
<p className="text-2xl font-bold text-blue-700 dark:text-blue-300">
{waiting}
</p>
</div>
</div>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
<div className="flex items-center gap-3">
<Clock3 className="h-8 w-8 text-yellow-600 dark:text-yellow-400" />
<div>
<p className="text-sm text-yellow-600 dark:text-yellow-400 font-medium">
Active
</p>
<p className="text-2xl font-bold text-yellow-700 dark:text-yellow-300">
{active}
</p>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<div className="flex items-center gap-3">
<Clock className="h-8 w-8 text-purple-600 dark:text-purple-400" />
<div>
<p className="text-sm text-purple-600 dark:text-purple-400 font-medium">
Delayed
</p>
<p className="text-2xl font-bold text-purple-700 dark:text-purple-300">
{delayed}
</p>
</div>
</div>
</div>
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
<div className="flex items-center gap-3">
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
<div>
<p className="text-sm text-red-600 dark:text-red-400 font-medium">
Failed
</p>
<p className="text-2xl font-bold text-red-700 dark:text-red-300">
{failed}
</p>
</div>
</div>
</div>
</div>
{/* Job History */}
<div>
{jobHistory.length === 0 ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400">
No job history found
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Job ID
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Job Name
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Attempt
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Date/Time
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Error/Output
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{jobHistory.map((job) => (
<tr
key={job.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-4 py-2 whitespace-nowrap text-xs font-mono text-secondary-900 dark:text-white">
{job.job_id}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{formatJobType(job.job_name)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
{getStatusIcon(job.status)}
<span
className={`text-xs font-medium ${getStatusColor(job.status)}`}
>
{job.status.charAt(0).toUpperCase() +
job.status.slice(1)}
</span>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{job.attempt_number}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{new Date(job.created_at).toLocaleString()}
</td>
<td className="px-4 py-2 text-xs">
{job.error_message ? (
<span className="text-red-600 dark:text-red-400">
{job.error_message}
</span>
) : job.output ? (
<span className="text-green-600 dark:text-green-400">
{JSON.stringify(job.output)}
</span>
) : (
<span className="text-secondary-500 dark:text-secondary-400">
-
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default HostDetail;

View File

@@ -56,6 +56,11 @@ export const dashboardAPI = {
const url = `/dashboard/hosts/${hostId}${queryString ? `?${queryString}` : ""}`;
return api.get(url);
},
getHostQueue: (hostId, params = {}) => {
const queryString = new URLSearchParams(params).toString();
const url = `/dashboard/hosts/${hostId}/queue${queryString ? `?${queryString}` : ""}`;
return api.get(url);
},
getPackageTrends: (params = {}) => {
const queryString = new URLSearchParams(params).toString();
const url = `/dashboard/package-trends${queryString ? `?${queryString}` : ""}`;

View File

@@ -37,6 +37,11 @@ export default defineConfig({
}
: undefined,
},
"/admin": {
target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
changeOrigin: true,
secure: false,
},
},
},
build: {

77
package-lock.json generated
View File

@@ -26,11 +26,12 @@
"version": "1.2.9",
"license": "AGPL-3.0",
"dependencies": {
"@bull-board/api": "^6.13.0",
"@bull-board/express": "^6.13.0",
"@bull-board/api": "^6.13.1",
"@bull-board/express": "^6.13.1",
"@prisma/client": "^6.1.0",
"bcryptjs": "^2.4.3",
"bullmq": "^5.61.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@@ -43,7 +44,8 @@
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0",
"uuid": "^11.0.3",
"winston": "^3.17.0"
"winston": "^3.17.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
@@ -564,36 +566,36 @@
}
},
"node_modules/@bull-board/api": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-6.13.0.tgz",
"integrity": "sha512-GZ0On0VeL5uZVS1x7UdU90F9GV1kdmHa1955hW3Ow1PmslCY/2YwmvnapVdbvCUSVBqluTfbVZsE9X3h79r1kw==",
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-6.13.1.tgz",
"integrity": "sha512-L9Ukfd/gxg8VIUb+vXRcU31yJsAaLLKG2qU/OMXQJ5EoXm2JhWBat+26YgrH/oKIb9zbZsg8xwHyqxa7sHEkVg==",
"license": "MIT",
"dependencies": {
"redis-info": "^3.1.0"
},
"peerDependencies": {
"@bull-board/ui": "6.13.0"
"@bull-board/ui": "6.13.1"
}
},
"node_modules/@bull-board/express": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-6.13.0.tgz",
"integrity": "sha512-PAbzD3dplV2NtN8ETs00bp++pBOD+cVb1BEYltXrjyViA2WluDBVKdlh/2wM+sHbYO2TAMNg8bUtKxGNCmxG7w==",
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-6.13.1.tgz",
"integrity": "sha512-wipvCsdeMdcgWVc77qrs858OjyGo7IAjJxuuWd4q5dvciFmTU1fmfZddWuZ1jDWpq5P7KdcpGxjzF1vnd2GaUw==",
"license": "MIT",
"dependencies": {
"@bull-board/api": "6.13.0",
"@bull-board/ui": "6.13.0",
"@bull-board/api": "6.13.1",
"@bull-board/ui": "6.13.1",
"ejs": "^3.1.10",
"express": "^4.21.1 || ^5.0.0"
}
},
"node_modules/@bull-board/ui": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.13.0.tgz",
"integrity": "sha512-63I6b3nZnKWI5ok6mw/Tk2rIObuzMTY/tLGyO51p0GW4rAImdXxrK6mT7j4SgEuP2B+tt/8L1jU7sLu8MMcCNw==",
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.13.1.tgz",
"integrity": "sha512-DzPjCFzjEbDukhfSd7nLdTLVKIv5waARQuAXETSRqiKTN4vSA1KNdaJ8p72YwHujKO19yFW1zWjNKrzsa8DCIg==",
"license": "MIT",
"dependencies": {
"@bull-board/api": "6.13.0"
"@bull-board/api": "6.13.1"
}
},
"node_modules/@colors/colors": {
@@ -2700,6 +2702,28 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -6569,6 +6593,27 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",