inital commit
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
const clientList = document.getElementById("client-list");
|
||||
const clientSearch = document.getElementById("client-search");
|
||||
const osFilter = document.getElementById("os-filter");
|
||||
const selectAllBtn = document.getElementById("select-all-btn");
|
||||
const clearSelectionBtn = document.getElementById("clear-selection-btn");
|
||||
const selectedCountSpan = document.getElementById("selected-count");
|
||||
const uploadZone = document.getElementById("upload-zone");
|
||||
const uploadInput = document.getElementById("upload-input");
|
||||
const uploadStatus = document.getElementById("upload-status");
|
||||
const osBadge = document.getElementById("os-badge");
|
||||
const execArgsInput = document.getElementById("exec-args");
|
||||
const hideWindowToggle = document.getElementById("hide-window-toggle");
|
||||
const executeBtn = document.getElementById("execute-btn");
|
||||
const updateBtn = document.getElementById("update-btn");
|
||||
const outputContainer = document.getElementById("output-container");
|
||||
const clearOutputBtn = document.getElementById("clear-output-btn");
|
||||
|
||||
let allClients = [];
|
||||
let filteredClients = [];
|
||||
const selectedClients = new Set();
|
||||
let uploaded = null;
|
||||
let allowedOs = "unknown";
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!res.ok) {
|
||||
window.location.href = "/login.html";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
document.getElementById("username-display").textContent = data.username;
|
||||
|
||||
const roleBadge = document.getElementById("role-badge");
|
||||
const roleBadges = {
|
||||
admin: '<i class="fa-solid fa-crown mr-1"></i>Admin',
|
||||
operator: '<i class="fa-solid fa-sliders mr-1"></i>Operator',
|
||||
viewer: '<i class="fa-solid fa-eye mr-1"></i>Viewer',
|
||||
};
|
||||
if (roleBadges[data.role]) {
|
||||
roleBadge.innerHTML = roleBadges[data.role];
|
||||
} else {
|
||||
roleBadge.textContent = data.role || "";
|
||||
}
|
||||
|
||||
if (data.role === "admin") {
|
||||
roleBadge.classList.add(
|
||||
"bg-purple-900/50",
|
||||
"text-purple-300",
|
||||
"border",
|
||||
"border-purple-800",
|
||||
);
|
||||
document.getElementById("metrics-link")?.classList.remove("hidden");
|
||||
document.getElementById("scripts-link")?.classList.remove("hidden");
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
document.getElementById("users-link")?.classList.remove("hidden");
|
||||
document.getElementById("plugins-link")?.classList.remove("hidden");
|
||||
document.getElementById("deploy-link")?.classList.remove("hidden");
|
||||
} else if (data.role === "operator") {
|
||||
roleBadge.classList.add(
|
||||
"bg-blue-900/50",
|
||||
"text-blue-300",
|
||||
"border",
|
||||
"border-blue-800",
|
||||
);
|
||||
} else {
|
||||
roleBadge.classList.add(
|
||||
"bg-slate-700",
|
||||
"text-slate-300",
|
||||
"border",
|
||||
"border-slate-600",
|
||||
);
|
||||
}
|
||||
|
||||
if (data.role !== "admin") {
|
||||
alert("Access denied. Admin role required.");
|
||||
window.location.href = "/";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Auth check failed:", err);
|
||||
window.location.href = "/login.html";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
const res = await fetch("/api/clients?pageSize=10000");
|
||||
if (!res.ok) throw new Error("Failed to load clients");
|
||||
|
||||
const data = await res.json();
|
||||
allClients = data.items.filter((c) => c.online);
|
||||
|
||||
if (allClients.length === 0) {
|
||||
clientList.innerHTML =
|
||||
'<div class="p-4 text-center text-slate-500">No online clients available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const osList = new Set(allClients.map((c) => c.os || "unknown"));
|
||||
osFilter.innerHTML =
|
||||
'<option value="all">All OS (' +
|
||||
allClients.length +
|
||||
")</option>" +
|
||||
Array.from(osList)
|
||||
.sort()
|
||||
.map((os) => {
|
||||
const count = allClients.filter((c) => (c.os || "unknown") === os)
|
||||
.length;
|
||||
return `<option value="${escapeHtml(os)}">${escapeHtml(os)} (${count})</option>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
filterAndRenderClients();
|
||||
preselectClientFromQuery();
|
||||
} catch (error) {
|
||||
console.error("Failed to load clients:", error);
|
||||
clientList.innerHTML =
|
||||
'<div class="p-4 text-center text-red-400">Error loading clients</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function preselectClientFromQuery() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const targetId = params.get("clientId");
|
||||
if (!targetId) return;
|
||||
if (allClients.some((c) => c.id === targetId)) {
|
||||
selectedClients.add(targetId);
|
||||
renderClients();
|
||||
}
|
||||
}
|
||||
|
||||
function filterAndRenderClients() {
|
||||
const searchTerm = clientSearch.value.toLowerCase();
|
||||
const osValue = osFilter.value;
|
||||
|
||||
filteredClients = allClients.filter((c) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
(c.host && c.host.toLowerCase().includes(searchTerm)) ||
|
||||
c.id.toLowerCase().includes(searchTerm) ||
|
||||
(c.os && c.os.toLowerCase().includes(searchTerm)) ||
|
||||
(c.user && c.user.toLowerCase().includes(searchTerm));
|
||||
|
||||
const matchesOs = osValue === "all" || (c.os || "unknown") === osValue;
|
||||
|
||||
const matchesUploadOs = matchesClientOs(c.os || "", allowedOs);
|
||||
|
||||
return matchesSearch && matchesOs && matchesUploadOs;
|
||||
});
|
||||
|
||||
renderClients();
|
||||
}
|
||||
|
||||
function renderClients() {
|
||||
if (filteredClients.length === 0) {
|
||||
clientList.innerHTML =
|
||||
'<div class="p-4 text-center text-slate-500">No clients match your filters</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
clientList.innerHTML = filteredClients
|
||||
.map((c) => {
|
||||
const name = c.host || c.id.substring(0, 8);
|
||||
const os = c.os || "unknown";
|
||||
const isSelected = selectedClients.has(c.id);
|
||||
|
||||
return `
|
||||
<label class="flex items-center gap-3 px-4 py-3 hover:bg-slate-800/50 cursor-pointer border-b border-slate-800 last:border-b-0" data-client-id="${escapeHtml(c.id)}">
|
||||
<input type="checkbox" class="client-checkbox w-4 h-4 rounded border-slate-600 bg-slate-700 checked:bg-cyan-600" data-id="${escapeHtml(c.id)}" ${
|
||||
isSelected ? "checked" : ""
|
||||
}>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-slate-100 truncate">${escapeHtml(
|
||||
name,
|
||||
)}</div>
|
||||
<div class="text-sm text-slate-400 flex items-center gap-2">
|
||||
<span>${escapeHtml(os)}</span>
|
||||
${c.user ? `<span class="text-slate-500">• ${escapeHtml(c.user)}</span>` : ""}
|
||||
<span class="text-slate-600">• ${c.id.substring(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-emerald-400 text-sm">
|
||||
<i class="fa-solid fa-circle text-xs"></i> Online
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
clientList.querySelectorAll(".client-checkbox").forEach((cb) => {
|
||||
cb.addEventListener("change", handleClientToggle);
|
||||
});
|
||||
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function handleClientToggle(e) {
|
||||
const clientId = e.target.dataset.id;
|
||||
if (e.target.checked) {
|
||||
selectedClients.add(clientId);
|
||||
} else {
|
||||
selectedClients.delete(clientId);
|
||||
}
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
selectedCountSpan.textContent = `${selectedClients.size} selected`;
|
||||
executeBtn.disabled = selectedClients.size === 0 || !uploaded;
|
||||
if (updateBtn) {
|
||||
updateBtn.disabled = selectedClients.size === 0 || !uploaded;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeClientOs(os) {
|
||||
const val = String(os || "").toLowerCase();
|
||||
if (val.includes("windows")) return "windows";
|
||||
if (val.includes("darwin") || val.includes("mac")) return "mac";
|
||||
if (val.includes("linux")) return "linux";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function matchesClientOs(clientOs, targetOs) {
|
||||
if (!targetOs || targetOs === "unknown") return true;
|
||||
if (targetOs === "unix") {
|
||||
const norm = normalizeClientOs(clientOs);
|
||||
return norm === "linux" || norm === "mac";
|
||||
}
|
||||
return normalizeClientOs(clientOs) === targetOs;
|
||||
}
|
||||
|
||||
function setUploadStatus(text, tone = "text-slate-400") {
|
||||
uploadStatus.className = `mt-3 text-sm ${tone}`;
|
||||
uploadStatus.textContent = text;
|
||||
}
|
||||
|
||||
function setOsBadge(os) {
|
||||
const label = os === "unknown" ? "not detected" : os === "unix" ? "mac/linux" : os;
|
||||
osBadge.textContent = `OS: ${label}`;
|
||||
osBadge.classList.remove("border-emerald-600", "text-emerald-300", "border-amber-600", "text-amber-300");
|
||||
if (os === "unknown") return;
|
||||
osBadge.classList.add("border-emerald-600", "text-emerald-300");
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes) || 0;
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||||
return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
clientSearch.addEventListener("input", filterAndRenderClients);
|
||||
osFilter.addEventListener("change", filterAndRenderClients);
|
||||
|
||||
selectAllBtn.addEventListener("click", () => {
|
||||
filteredClients.forEach((c) => selectedClients.add(c.id));
|
||||
renderClients();
|
||||
});
|
||||
|
||||
clearSelectionBtn.addEventListener("click", () => {
|
||||
selectedClients.clear();
|
||||
renderClients();
|
||||
});
|
||||
|
||||
uploadZone.addEventListener("click", () => uploadInput.click());
|
||||
uploadZone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
uploadZone.classList.add("border-cyan-500");
|
||||
});
|
||||
uploadZone.addEventListener("dragleave", () => {
|
||||
uploadZone.classList.remove("border-cyan-500");
|
||||
});
|
||||
uploadZone.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
uploadZone.classList.remove("border-cyan-500");
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
});
|
||||
uploadInput.addEventListener("change", async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
async function uploadFile(file) {
|
||||
setUploadStatus(`Uploading ${file.name}...`, "text-blue-400");
|
||||
executeBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const res = await fetch("/api/deploy/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
setUploadStatus(text || "Upload failed", "text-red-400");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setUploadStatus(data.error || "Upload failed", "text-red-400");
|
||||
return;
|
||||
}
|
||||
|
||||
uploaded = data;
|
||||
allowedOs = data.os || "unknown";
|
||||
const sizeBytes = Number(data.size ?? file.size ?? 0);
|
||||
setUploadStatus(`Uploaded ${data.name} (${formatBytes(sizeBytes)})`, "text-emerald-400");
|
||||
setOsBadge(allowedOs);
|
||||
|
||||
filterAndRenderClients();
|
||||
Array.from(selectedClients).forEach((clientId) => {
|
||||
const client = allClients.find((c) => c.id === clientId);
|
||||
if (!client || !matchesClientOs(client.os || "", allowedOs)) {
|
||||
selectedClients.delete(clientId);
|
||||
}
|
||||
});
|
||||
renderClients();
|
||||
updateSelectedCount();
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
setUploadStatus("Upload failed", "text-red-400");
|
||||
}
|
||||
}
|
||||
|
||||
executeBtn.addEventListener("click", async () => {
|
||||
if (selectedClients.size === 0) {
|
||||
alert("Please select at least one client");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uploaded?.uploadId) {
|
||||
alert("Please upload an installer first");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = execArgsInput.value.trim();
|
||||
const hideWindow = hideWindowToggle?.checked !== false;
|
||||
|
||||
executeBtn.disabled = true;
|
||||
const clientIds = Array.from(selectedClients);
|
||||
outputContainer.innerHTML = `<div class="text-blue-400">Dispatching to ${clientIds.length} client(s)...</div>`;
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
const res = await fetch("/api/deploy/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
uploadId: uploaded.uploadId,
|
||||
clientIds,
|
||||
args,
|
||||
hideWindow,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
results = clientIds.map((clientId) => ({
|
||||
clientId,
|
||||
error: `HTTP error! status: ${res.status}`,
|
||||
}));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
results = clientIds.map((clientId) => ({
|
||||
clientId,
|
||||
error: data.error || "Unknown error",
|
||||
}));
|
||||
} else {
|
||||
results = (data.results || []).map((result) => ({
|
||||
clientId: result.clientId,
|
||||
ok: result.ok,
|
||||
reason: result.reason,
|
||||
command: result.command,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results = clientIds.map((clientId) => ({
|
||||
clientId,
|
||||
error: error.message,
|
||||
}));
|
||||
}
|
||||
|
||||
const namedResults = results.map((r) => {
|
||||
const client = allClients.find((c) => c.id === r.clientId);
|
||||
const clientName = client
|
||||
? client.host || r.clientId.substring(0, 8)
|
||||
: r.clientId.substring(0, 8);
|
||||
if (r.error) {
|
||||
return { clientName, clientId: r.clientId, error: r.error };
|
||||
}
|
||||
if (r.ok === false) {
|
||||
return { clientName, clientId: r.clientId, error: r.reason || "Dispatch failed" };
|
||||
}
|
||||
return { clientName, clientId: r.clientId, output: "Queued", command: r.command };
|
||||
});
|
||||
|
||||
outputContainer.innerHTML = namedResults
|
||||
.map((r) => {
|
||||
if (r.error) {
|
||||
return `<div class="mb-4 pb-4 border-b border-slate-800 last:border-b-0">
|
||||
<div class="text-cyan-400 font-semibold mb-2">━━━ ${escapeHtml(
|
||||
r.clientName,
|
||||
)} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━</div>
|
||||
<div class="text-red-400">Error: ${escapeHtml(r.error)}</div>
|
||||
</div>`;
|
||||
}
|
||||
const commandLine = r.command
|
||||
? `<div class="text-slate-400 text-sm mb-2">Command: ${escapeHtml(r.command)}</div>`
|
||||
: "";
|
||||
return `<div class="mb-4 pb-4 border-b border-slate-800 last:border-b-0">
|
||||
<div class="text-cyan-400 font-semibold mb-2">━━━ ${escapeHtml(
|
||||
r.clientName,
|
||||
)} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━</div>
|
||||
${commandLine}
|
||||
<div class="text-slate-100">${escapeHtml(r.output)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
executeBtn.disabled = false;
|
||||
});
|
||||
|
||||
if (updateBtn) {
|
||||
updateBtn.addEventListener("click", async () => {
|
||||
if (selectedClients.size === 0) {
|
||||
alert("Please select at least one client");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uploaded?.uploadId) {
|
||||
alert("Please upload an installer first");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("Replace the running agent on selected clients?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateBtn.disabled = true;
|
||||
const clientIds = Array.from(selectedClients);
|
||||
outputContainer.innerHTML = `<div class="text-blue-400">Dispatching update to ${clientIds.length} client(s)...</div>`;
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
const res = await fetch("/api/deploy/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
uploadId: uploaded.uploadId,
|
||||
clientIds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
results = clientIds.map((clientId) => ({
|
||||
clientId,
|
||||
error: `HTTP error! status: ${res.status}`,
|
||||
}));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
results = clientIds.map((clientId) => ({
|
||||
clientId,
|
||||
error: data.error || "Unknown error",
|
||||
}));
|
||||
} else {
|
||||
results = (data.results || []).map((result) => ({
|
||||
clientId: result.clientId,
|
||||
ok: result.ok,
|
||||
reason: result.reason,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results = clientIds.map((clientId) => ({
|
||||
clientId,
|
||||
error: error.message,
|
||||
}));
|
||||
}
|
||||
|
||||
const namedResults = results.map((r) => {
|
||||
const client = allClients.find((c) => c.id === r.clientId);
|
||||
const clientName = client
|
||||
? client.host || r.clientId.substring(0, 8)
|
||||
: r.clientId.substring(0, 8);
|
||||
if (r.error) {
|
||||
return { clientName, clientId: r.clientId, error: r.error };
|
||||
}
|
||||
if (r.ok === false) {
|
||||
return { clientName, clientId: r.clientId, error: r.reason || "Update failed" };
|
||||
}
|
||||
return { clientName, clientId: r.clientId, output: "Update dispatched" };
|
||||
});
|
||||
|
||||
outputContainer.innerHTML = namedResults
|
||||
.map((r) => {
|
||||
if (r.error) {
|
||||
return `<div class="mb-4 pb-4 border-b border-slate-800 last:border-b-0">
|
||||
<div class="text-cyan-400 font-semibold mb-2">━━━ ${escapeHtml(
|
||||
r.clientName,
|
||||
)} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━</div>
|
||||
<div class="text-red-400">Error: ${escapeHtml(r.error)}</div>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="mb-4 pb-4 border-b border-slate-800 last:border-b-0">
|
||||
<div class="text-cyan-400 font-semibold mb-2">━━━ ${escapeHtml(
|
||||
r.clientName,
|
||||
)} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━</div>
|
||||
<div class="text-slate-100">${escapeHtml(r.output)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
updateBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
clearOutputBtn.addEventListener("click", () => {
|
||||
outputContainer.innerHTML =
|
||||
'<div class="text-slate-500">No commands dispatched yet.</div>';
|
||||
});
|
||||
|
||||
checkAuth();
|
||||
loadClients();
|
||||
Reference in New Issue
Block a user