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

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: {