tiny update
This commit is contained in:
@@ -302,7 +302,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
state === "idle" ? "Stopped" :
|
||||
"Connecting");
|
||||
|
||||
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
|
||||
statusEl.innerHTML = icons[state] || icons.idle;
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.textContent = String(label || "");
|
||||
statusEl.appendChild(labelEl);
|
||||
const base = "inline-flex items-center gap-2 px-3 py-2 rounded-full border text-sm";
|
||||
const styles = {
|
||||
streaming: "bg-violet-900/40 text-violet-100 border-violet-700/70",
|
||||
@@ -915,7 +918,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function sendCmd(type, payload) {
|
||||
@@ -2038,7 +2041,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
const lookupBtn = document.getElementById("backstageLookupBtn");
|
||||
if (!resultsEl) return;
|
||||
|
||||
if (msg.done) {
|
||||
if (msg?.done) {
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<i class="fa-solid fa-check mr-1 text-emerald-400"></i>Search complete';
|
||||
setTimeout(() => statusEl.classList.add("hidden"), 3000);
|
||||
@@ -2053,23 +2056,31 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.path) {
|
||||
const lookupPath = safeClientString(msg?.path, "", 32768);
|
||||
if (lookupPath) {
|
||||
// Remove placeholder if present
|
||||
const placeholder = resultsEl.querySelector(":scope > div:not([data-lookup-path])");
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.dataset.lookupPath = msg.path;
|
||||
item.dataset.lookupPath = lookupPath;
|
||||
item.className = "w-full text-left px-2 py-1.5 rounded text-xs text-slate-200 hover:bg-violet-600/30 hover:text-violet-100 transition-colors font-mono truncate block";
|
||||
item.textContent = msg.path;
|
||||
item.title = "Click to start in backstage: " + msg.path;
|
||||
item.textContent = lookupPath;
|
||||
item.title = "Click to start in backstage: " + lookupPath;
|
||||
item.addEventListener("click", () => {
|
||||
const killCheckbox = document.getElementById("backstageLookupKill");
|
||||
const killExe = killCheckbox?.checked ? msg.exe : "";
|
||||
sendCmd("backstage_start_process", { path: '"' + msg.path + '"', kill_exe: killExe });
|
||||
const killExe = killCheckbox?.checked
|
||||
? safeClientString(msg?.exe, "", 1024)
|
||||
: "";
|
||||
sendCmd("backstage_start_process", { path: '"' + lookupPath + '"', kill_exe: killExe });
|
||||
item.classList.add("text-emerald-400");
|
||||
item.innerHTML = '<i class="fa-solid fa-check mr-1"></i>' + (killExe ? '(killed) ' : '') + msg.path;
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "fa-solid fa-check mr-1";
|
||||
item.replaceChildren(
|
||||
icon,
|
||||
document.createTextNode((killExe ? "(killed) " : "") + lookupPath),
|
||||
);
|
||||
});
|
||||
resultsEl.appendChild(item);
|
||||
item.scrollIntoView({ block: "nearest" });
|
||||
@@ -2096,12 +2107,144 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
"#ec4899", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
|
||||
];
|
||||
|
||||
const WINDOW_MAP_MAX_MONITORS = 64;
|
||||
const WINDOW_MAP_MAX_WINDOWS = 2000;
|
||||
const WINDOW_MAP_MAX_COORDINATE = 1000000;
|
||||
const WINDOW_MAP_MAX_DIMENSION = 100000;
|
||||
|
||||
function safeClientString(value, fallback = "", maxLength = 1024) {
|
||||
let text;
|
||||
try {
|
||||
text = String(value ?? fallback);
|
||||
} catch {
|
||||
text = String(fallback);
|
||||
}
|
||||
return text.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function boundedClientNumber(value, min, max, fallback = 0) {
|
||||
let number;
|
||||
try {
|
||||
number = Number(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.min(max, Math.max(min, number));
|
||||
}
|
||||
|
||||
function boundedClientInteger(value, min, max, fallback = 0) {
|
||||
return Math.trunc(boundedClientNumber(value, min, max, fallback));
|
||||
}
|
||||
|
||||
function normalizeWindowMapPayload(msg) {
|
||||
const monitorValues = Array.isArray(msg?.monitors)
|
||||
? msg.monitors.slice(0, WINDOW_MAP_MAX_MONITORS)
|
||||
: [];
|
||||
const windowValues = Array.isArray(msg?.windows)
|
||||
? msg.windows.slice(0, WINDOW_MAP_MAX_WINDOWS)
|
||||
: [];
|
||||
|
||||
const monitors = monitorValues.map((value, position) => {
|
||||
const monitor = value && typeof value === "object" ? value : {};
|
||||
return {
|
||||
index: boundedClientInteger(
|
||||
monitor.index,
|
||||
-WINDOW_MAP_MAX_COORDINATE,
|
||||
WINDOW_MAP_MAX_COORDINATE,
|
||||
position,
|
||||
),
|
||||
name: safeClientString(monitor.name, "", 1024),
|
||||
x: boundedClientNumber(
|
||||
monitor.x,
|
||||
-WINDOW_MAP_MAX_COORDINATE,
|
||||
WINDOW_MAP_MAX_COORDINATE,
|
||||
),
|
||||
y: boundedClientNumber(
|
||||
monitor.y,
|
||||
-WINDOW_MAP_MAX_COORDINATE,
|
||||
WINDOW_MAP_MAX_COORDINATE,
|
||||
),
|
||||
width: boundedClientNumber(
|
||||
monitor.width,
|
||||
0,
|
||||
WINDOW_MAP_MAX_DIMENSION,
|
||||
),
|
||||
height: boundedClientNumber(
|
||||
monitor.height,
|
||||
0,
|
||||
WINDOW_MAP_MAX_DIMENSION,
|
||||
),
|
||||
primary: monitor.primary === true,
|
||||
};
|
||||
});
|
||||
|
||||
const windows = windowValues.map((value) => {
|
||||
const windowValue = value && typeof value === "object" ? value : {};
|
||||
return {
|
||||
title: safeClientString(windowValue.title, "", 2048),
|
||||
processName: safeClientString(windowValue.processName, "", 1024),
|
||||
pid: boundedClientInteger(windowValue.pid, 0, 0xffffffff, 0),
|
||||
x: boundedClientNumber(
|
||||
windowValue.x,
|
||||
-WINDOW_MAP_MAX_COORDINATE,
|
||||
WINDOW_MAP_MAX_COORDINATE,
|
||||
),
|
||||
y: boundedClientNumber(
|
||||
windowValue.y,
|
||||
-WINDOW_MAP_MAX_COORDINATE,
|
||||
WINDOW_MAP_MAX_COORDINATE,
|
||||
),
|
||||
width: boundedClientNumber(
|
||||
windowValue.width,
|
||||
0,
|
||||
WINDOW_MAP_MAX_DIMENSION,
|
||||
),
|
||||
height: boundedClientNumber(
|
||||
windowValue.height,
|
||||
0,
|
||||
WINDOW_MAP_MAX_DIMENSION,
|
||||
),
|
||||
monitor: boundedClientInteger(
|
||||
windowValue.monitor,
|
||||
-1,
|
||||
WINDOW_MAP_MAX_MONITORS - 1,
|
||||
-1,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return { monitors, windows };
|
||||
}
|
||||
|
||||
function setWindowMapPlaceholder(element, text, { canvas = false, spinner = false } = {}) {
|
||||
if (!element) return;
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = canvas
|
||||
? "flex items-center justify-center h-40 text-slate-500 text-sm"
|
||||
: "text-slate-500 text-xs text-center py-2";
|
||||
if (spinner) {
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "fa-solid fa-spinner fa-spin mr-2";
|
||||
wrapper.appendChild(icon);
|
||||
}
|
||||
wrapper.appendChild(document.createTextNode(text));
|
||||
element.replaceChildren(wrapper);
|
||||
}
|
||||
|
||||
function createWindowMapSpan(className, text = "") {
|
||||
const span = document.createElement("span");
|
||||
span.className = className;
|
||||
span.textContent = safeClientString(text, "", 4096);
|
||||
return span;
|
||||
}
|
||||
|
||||
function openWindowMap() {
|
||||
if (!windowMapModal) return;
|
||||
windowMapModal.style.display = "flex";
|
||||
sendCmd("backstage_window_list", {});
|
||||
if (windowMapCanvas) windowMapCanvas.innerHTML = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm"><i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading…</div>';
|
||||
if (windowMapList) windowMapList.innerHTML = "";
|
||||
setWindowMapPlaceholder(windowMapCanvas, "Loading…", { canvas: true, spinner: true });
|
||||
windowMapList?.replaceChildren();
|
||||
}
|
||||
|
||||
function closeWindowMap() {
|
||||
@@ -2112,22 +2255,21 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
if (windowMapCloseBtn) windowMapCloseBtn.addEventListener("click", closeWindowMap);
|
||||
if (windowMapRefreshBtn) windowMapRefreshBtn.addEventListener("click", () => {
|
||||
sendCmd("backstage_window_list", {});
|
||||
if (windowMapCanvas) windowMapCanvas.innerHTML = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm"><i class="fa-solid fa-spinner fa-spin mr-2"></i>Refreshing…</div>';
|
||||
if (windowMapList) windowMapList.innerHTML = "";
|
||||
setWindowMapPlaceholder(windowMapCanvas, "Refreshing…", { canvas: true, spinner: true });
|
||||
windowMapList?.replaceChildren();
|
||||
});
|
||||
if (windowMapModal) windowMapModal.addEventListener("click", (e) => {
|
||||
if (e.target === windowMapModal) closeWindowMap();
|
||||
});
|
||||
|
||||
function handleWindowListResult(msg) {
|
||||
const monitors = msg.monitors || [];
|
||||
const windows = msg.windows || [];
|
||||
const { monitors, windows } = normalizeWindowMapPayload(msg);
|
||||
|
||||
if (!windowMapCanvas || !windowMapList) return;
|
||||
|
||||
if (monitors.length === 0 && windows.length === 0) {
|
||||
windowMapCanvas.innerHTML = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm">No windows or monitors found</div>';
|
||||
windowMapList.innerHTML = '<div class="text-slate-500 text-xs text-center py-2">No visible windows on the hidden desktop</div>';
|
||||
setWindowMapPlaceholder(windowMapCanvas, "No windows or monitors found", { canvas: true });
|
||||
setWindowMapPlaceholder(windowMapList, "No visible windows on the hidden desktop");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2159,58 +2301,116 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
function ty(y) { return padY + (y - minY) * scale; }
|
||||
function tw(w) { return w * scale; }
|
||||
|
||||
let html = "";
|
||||
windowMapCanvas.style.height = mapH + "px";
|
||||
const canvasFragment = document.createDocumentFragment();
|
||||
|
||||
for (let i = 0; i < monitors.length; i++) {
|
||||
const m = monitors[i];
|
||||
const x = tx(m.x), y = ty(m.y), w = tw(m.width), h = tw(m.height);
|
||||
html += `<div class="absolute border-2 border-dashed border-slate-600 rounded" style="left:${x}px;top:${y}px;width:${w}px;height:${h}px;" title="Monitor ${m.index}: ${m.name} (${m.width}x${m.height})">
|
||||
<span class="absolute top-1 left-1.5 text-[10px] font-mono text-slate-500">${m.primary ? "★ " : ""}${m.name || "Monitor " + m.index}</span>
|
||||
<span class="absolute bottom-1 right-1.5 text-[10px] font-mono text-slate-600">${m.width}×${m.height}</span>
|
||||
</div>`;
|
||||
const monitorBox = document.createElement("div");
|
||||
monitorBox.className = "absolute border-2 border-dashed border-slate-600 rounded";
|
||||
monitorBox.style.left = `${x}px`;
|
||||
monitorBox.style.top = `${y}px`;
|
||||
monitorBox.style.width = `${w}px`;
|
||||
monitorBox.style.height = `${h}px`;
|
||||
monitorBox.title = `Monitor ${m.index}: ${m.name} (${m.width}x${m.height})`;
|
||||
monitorBox.append(
|
||||
createWindowMapSpan(
|
||||
"absolute top-1 left-1.5 text-[10px] font-mono text-slate-500",
|
||||
`${m.primary ? "★ " : ""}${m.name || "Monitor " + m.index}`,
|
||||
),
|
||||
createWindowMapSpan(
|
||||
"absolute bottom-1 right-1.5 text-[10px] font-mono text-slate-600",
|
||||
`${m.width}×${m.height}`,
|
||||
),
|
||||
);
|
||||
canvasFragment.appendChild(monitorBox);
|
||||
}
|
||||
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
const w = windows[i];
|
||||
const mappedWindow = windows[i];
|
||||
const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
|
||||
const x = tx(w.x), y = ty(w.y), ww = Math.max(tw(w.width), 8), wh = Math.max(tw(w.height), 8);
|
||||
const shortTitle = w.title.length > 30 ? w.title.slice(0, 28) + "…" : w.title;
|
||||
html += `<div class="absolute rounded border overflow-hidden cursor-default" style="left:${x}px;top:${y}px;width:${ww}px;height:${wh}px;border-color:${color};background:${color}18;" title="${escHtml(w.title)}\n${w.processName} (PID ${w.pid})\nPosition: ${w.x},${w.y} Size: ${w.width}×${w.height}\nMonitor: ${w.monitor >= 0 ? w.monitor : "none"}">
|
||||
${wh > 20 && ww > 50 ? `<div class="px-1 py-0.5 text-[9px] font-mono text-slate-200 truncate" style="background:${color}50;">${escHtml(shortTitle)}</div>` : ""}
|
||||
</div>`;
|
||||
const x = tx(mappedWindow.x), y = ty(mappedWindow.y);
|
||||
const width = Math.max(tw(mappedWindow.width), 8);
|
||||
const height = Math.max(tw(mappedWindow.height), 8);
|
||||
const shortTitle = mappedWindow.title.length > 30
|
||||
? mappedWindow.title.slice(0, 28) + "…"
|
||||
: mappedWindow.title;
|
||||
const windowBox = document.createElement("div");
|
||||
windowBox.className = "absolute rounded border overflow-hidden cursor-default";
|
||||
windowBox.style.left = `${x}px`;
|
||||
windowBox.style.top = `${y}px`;
|
||||
windowBox.style.width = `${width}px`;
|
||||
windowBox.style.height = `${height}px`;
|
||||
windowBox.style.borderColor = color;
|
||||
windowBox.style.background = `${color}18`;
|
||||
windowBox.title = [
|
||||
mappedWindow.title,
|
||||
`${mappedWindow.processName} (PID ${mappedWindow.pid})`,
|
||||
`Position: ${mappedWindow.x},${mappedWindow.y} Size: ${mappedWindow.width}×${mappedWindow.height}`,
|
||||
`Monitor: ${mappedWindow.monitor >= 0 ? mappedWindow.monitor : "none"}`,
|
||||
].join("\n");
|
||||
if (height > 20 && width > 50) {
|
||||
const title = document.createElement("div");
|
||||
title.className = "px-1 py-0.5 text-[9px] font-mono text-slate-200 truncate";
|
||||
title.style.background = `${color}50`;
|
||||
title.textContent = shortTitle;
|
||||
windowBox.appendChild(title);
|
||||
}
|
||||
canvasFragment.appendChild(windowBox);
|
||||
}
|
||||
|
||||
windowMapCanvas.innerHTML = html;
|
||||
|
||||
let listHtml = `<div class="grid gap-1">
|
||||
<div class="grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-500 border-b border-slate-800">
|
||||
<span></span><span>Title</span><span>Process</span><span>Position</span><span>Monitor</span>
|
||||
</div>`;
|
||||
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
const w = windows[i];
|
||||
const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
|
||||
const monLabel = w.monitor >= 0 ? (monitors[w.monitor] ? (monitors[w.monitor].name || "#" + w.monitor) : "#" + w.monitor) : "—";
|
||||
listHtml += `<div class="grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 items-center px-2 py-1.5 rounded hover:bg-slate-800/50 text-xs text-slate-300">
|
||||
<span class="w-2.5 h-2.5 rounded-sm" style="background:${color};"></span>
|
||||
<span class="truncate font-medium" title="${escHtml(w.title)}">${escHtml(w.title)}</span>
|
||||
<span class="font-mono text-slate-400">${escHtml(w.processName)} <span class="text-slate-600">(${w.pid})</span></span>
|
||||
<span class="font-mono text-slate-500">${w.x},${w.y} ${w.width}×${w.height}</span>
|
||||
<span class="font-mono ${w.monitor >= 0 ? "text-slate-300" : "text-rose-400"}">${monLabel}</span>
|
||||
</div>`;
|
||||
}
|
||||
listHtml += "</div>";
|
||||
windowMapCanvas.replaceChildren(canvasFragment);
|
||||
|
||||
if (windows.length === 0) {
|
||||
listHtml = '<div class="text-slate-500 text-xs text-center py-2">No visible windows on the hidden desktop</div>';
|
||||
setWindowMapPlaceholder(windowMapList, "No visible windows on the hidden desktop");
|
||||
return;
|
||||
}
|
||||
|
||||
windowMapList.innerHTML = listHtml;
|
||||
}
|
||||
const list = document.createElement("div");
|
||||
list.className = "grid gap-1";
|
||||
const header = document.createElement("div");
|
||||
header.className = "grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-500 border-b border-slate-800";
|
||||
header.append(
|
||||
createWindowMapSpan("", ""),
|
||||
createWindowMapSpan("", "Title"),
|
||||
createWindowMapSpan("", "Process"),
|
||||
createWindowMapSpan("", "Position"),
|
||||
createWindowMapSpan("", "Monitor"),
|
||||
);
|
||||
list.appendChild(header);
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
const mappedWindow = windows[i];
|
||||
const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
|
||||
const monitorLabel = mappedWindow.monitor >= 0
|
||||
? (monitors[mappedWindow.monitor]?.name || `#${mappedWindow.monitor}`)
|
||||
: "—";
|
||||
const row = document.createElement("div");
|
||||
row.className = "grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 items-center px-2 py-1.5 rounded hover:bg-slate-800/50 text-xs text-slate-300";
|
||||
|
||||
const colorMarker = createWindowMapSpan("w-2.5 h-2.5 rounded-sm", "");
|
||||
colorMarker.style.background = color;
|
||||
const title = createWindowMapSpan("truncate font-medium", mappedWindow.title);
|
||||
title.title = mappedWindow.title;
|
||||
const process = createWindowMapSpan("font-mono text-slate-400", mappedWindow.processName + " ");
|
||||
process.appendChild(createWindowMapSpan("text-slate-600", `(${mappedWindow.pid})`));
|
||||
row.append(
|
||||
colorMarker,
|
||||
title,
|
||||
process,
|
||||
createWindowMapSpan(
|
||||
"font-mono text-slate-500",
|
||||
`${mappedWindow.x},${mappedWindow.y} ${mappedWindow.width}×${mappedWindow.height}`,
|
||||
),
|
||||
createWindowMapSpan(
|
||||
`font-mono ${mappedWindow.monitor >= 0 ? "text-slate-300" : "text-rose-400"}`,
|
||||
monitorLabel,
|
||||
),
|
||||
);
|
||||
list.appendChild(row);
|
||||
}
|
||||
windowMapList.replaceChildren(list);
|
||||
}
|
||||
|
||||
connectWs();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
staggerChildren,
|
||||
} from "./motion.js";
|
||||
import { isLocalServerAddress } from "./build-server-url.js";
|
||||
import { escapeHtml } from "./format.js";
|
||||
|
||||
const form = document.getElementById("build-form");
|
||||
const buildBtn = document.getElementById("build-btn");
|
||||
@@ -2912,25 +2913,29 @@ if (buildUpdateAllBtn) {
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
updateAllModalBody.innerHTML = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>${data.error || "Failed to check eligible clients"}</p>`;
|
||||
updateAllModalBody.innerHTML = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>${escapeHtml(data.error || "Failed to check eligible clients")}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const eligible = Math.max(0, Math.trunc(Number(data.eligible) || 0));
|
||||
const totalOnline = Math.max(0, Math.trunc(Number(data.totalOnline) || 0));
|
||||
const skippedInMemory = Math.max(0, Math.trunc(Number(data.skippedInMemory) || 0));
|
||||
const skippedNoMatch = Math.max(0, Math.trunc(Number(data.skippedNoMatch) || 0));
|
||||
let html = "";
|
||||
|
||||
html += `<p class="text-white"><i class="fa-solid fa-users mr-2 text-amber-400"></i><strong>${data.eligible}</strong> client(s) will receive the update after build.</p>`;
|
||||
html += `<p class="text-white"><i class="fa-solid fa-users mr-2 text-amber-400"></i><strong>${eligible}</strong> client(s) will receive the update after build.</p>`;
|
||||
html += '<div class="mt-2 text-xs text-slate-400 space-y-1">';
|
||||
html += `<p><i class="fa-solid fa-globe mr-1 text-blue-400"></i> ${data.totalOnline} total online client(s)</p>`;
|
||||
if (data.skippedInMemory > 0) {
|
||||
html += `<p><i class="fa-solid fa-memory mr-1 text-red-400"></i> ${data.skippedInMemory} client(s) will be skipped (running in-memory)</p>`;
|
||||
html += `<p><i class="fa-solid fa-globe mr-1 text-blue-400"></i> ${totalOnline} total online client(s)</p>`;
|
||||
if (skippedInMemory > 0) {
|
||||
html += `<p><i class="fa-solid fa-memory mr-1 text-red-400"></i> ${skippedInMemory} client(s) will be skipped (running in-memory)</p>`;
|
||||
}
|
||||
if (data.skippedNoMatch > 0) {
|
||||
html += `<p><i class="fa-solid fa-ban mr-1 text-slate-500"></i> ${data.skippedNoMatch} client(s) will be skipped (no matching platform)</p>`;
|
||||
if (skippedNoMatch > 0) {
|
||||
html += `<p><i class="fa-solid fa-ban mr-1 text-slate-500"></i> ${skippedNoMatch} client(s) will be skipped (no matching platform)</p>`;
|
||||
}
|
||||
html += "</div>";
|
||||
|
||||
if (data.eligible === 0 && data.totalOnline === 0) {
|
||||
if (eligible === 0 && totalOnline === 0) {
|
||||
html += '<p class="mt-3 text-xs text-slate-500">No clients are currently online. The build will still run but no updates will be sent.</p>';
|
||||
}
|
||||
|
||||
@@ -2939,7 +2944,8 @@ if (buildUpdateAllBtn) {
|
||||
|
||||
updateAllModalBody.innerHTML = html;
|
||||
} catch (err) {
|
||||
updateAllModalBody.innerHTML = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>Error: ${err.message}</p>`;
|
||||
const message = err instanceof Error ? err.message : String(err || "Unknown error");
|
||||
updateAllModalBody.innerHTML = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>Error: ${escapeHtml(message)}</p>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const ROLE_COLORS = {
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
|
||||
@@ -71,7 +71,8 @@ function escapeHtml(value) {
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function setSummary(summary) {
|
||||
|
||||
@@ -121,9 +121,26 @@ function pushRecent(href) {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function safeClassTokens(value, fallback = "") {
|
||||
const tokens = String(value || "")
|
||||
.split(/\s+/)
|
||||
.filter((token) => /^[A-Za-z0-9_-]+$/.test(token));
|
||||
return tokens.length ? tokens.join(" ") : fallback;
|
||||
}
|
||||
|
||||
function flagFor(country) {
|
||||
if (!country || country.length !== 2) return "";
|
||||
return `<span class="fi fi-${country.toLowerCase()}" style="margin-right:6px;border-radius:2px;"></span>`;
|
||||
const code = String(country || "").trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(code)) return "";
|
||||
return `<span class="fi fi-${code.toLowerCase()}" style="margin-right:6px;border-radius:2px;"></span>`;
|
||||
}
|
||||
|
||||
function clientLabel(c) {
|
||||
@@ -163,15 +180,17 @@ function buildResults(query, pages, clients) {
|
||||
}
|
||||
|
||||
function makeIcon(faClass, colorClass) {
|
||||
return `<i class="fa-solid ${faClass} ${colorClass || "text-slate-300"}" style="width:18px;text-align:center;"></i>`;
|
||||
const icon = safeClassTokens(faClass, "fa-circle-question");
|
||||
const color = safeClassTokens(colorClass, "text-slate-300");
|
||||
return `<i class="fa-solid ${icon} ${color}" style="width:18px;text-align:center;"></i>`;
|
||||
}
|
||||
|
||||
function renderRow(r, active) {
|
||||
const activeCls = active ? "cmdp-row cmdp-row-active" : "cmdp-row";
|
||||
if (r.kind === "page") {
|
||||
return `<div class="${activeCls}" data-href="${r.href}">
|
||||
return `<div class="${activeCls}" data-href="${escapeHtml(r.href)}">
|
||||
${makeIcon(r.icon, r.color)}
|
||||
<span class="cmdp-label">${r.label}</span>
|
||||
<span class="cmdp-label">${escapeHtml(r.label)}</span>
|
||||
<span class="cmdp-kind">page</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -179,26 +198,29 @@ function renderRow(r, active) {
|
||||
const c = r.client;
|
||||
const dot = c.online ? "background:#10b981" : "background:#475569";
|
||||
const os = osBadge(c.os || "");
|
||||
return `<div class="${activeCls}" data-client="${c.id}">
|
||||
<i class="fa ${os.icon} cv-tone-${os.tone}" style="width:18px;text-align:center;"></i>
|
||||
const osIcon = safeClassTokens(os.icon, "fa-solid fa-circle-question");
|
||||
const osTone = safeClassTokens(os.tone, "pill-unknown");
|
||||
return `<div class="${activeCls}" data-client="${escapeHtml(c.id)}">
|
||||
<i class="fa ${osIcon} cv-tone-${osTone}" style="width:18px;text-align:center;"></i>
|
||||
<span class="cmdp-status-dot" style="${dot}"></span>
|
||||
${flagFor(c.country)}
|
||||
<span class="cmdp-label">${r.label}</span>
|
||||
<span class="cmdp-label">${escapeHtml(r.label)}</span>
|
||||
<span class="cmdp-kind">${c.online ? "online" : "offline"}</span>
|
||||
</div>`;
|
||||
}
|
||||
if (r.kind === "action") {
|
||||
return `<div class="${activeCls}" data-action="${r.action.key}">
|
||||
return `<div class="${activeCls}" data-action="${escapeHtml(r.action.key)}">
|
||||
${makeIcon(r.action.icon, r.action.color)}
|
||||
<span class="cmdp-label">${r.action.label}</span>
|
||||
<span class="cmdp-label">${escapeHtml(r.action.label)}</span>
|
||||
<span class="cmdp-kind">needs client</span>
|
||||
</div>`;
|
||||
}
|
||||
if (r.kind === "client-action") {
|
||||
const c = r.client;
|
||||
return `<div class="${activeCls}" data-href="${r.action.href(c.id)}">
|
||||
const encodedId = encodeURIComponent(String(c.id || ""));
|
||||
return `<div class="${activeCls}" data-href="${escapeHtml(r.action.href(encodedId))}">
|
||||
${makeIcon(r.action.icon, r.action.color)}
|
||||
<span class="cmdp-label">${r.action.label} · ${clientLabel(c)}</span>
|
||||
<span class="cmdp-label">${escapeHtml(`${r.action.label} · ${clientLabel(c)}`)}</span>
|
||||
<span class="cmdp-kind">action</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -320,7 +342,7 @@ function renderList() {
|
||||
}
|
||||
let html = "";
|
||||
if (state.mode === "client-actions" && state.pendingAction) {
|
||||
html += `<div class="cmdp-section">Pick a client for: ${state.pendingAction.label}</div>`;
|
||||
html += `<div class="cmdp-section">Pick a client for: ${escapeHtml(state.pendingAction.label)}</div>`;
|
||||
}
|
||||
html += state.results.map((r, i) => renderRow(r, i === state.active)).join("");
|
||||
list.innerHTML = html;
|
||||
@@ -336,7 +358,12 @@ function activate(idx, newTab) {
|
||||
const r = state.results[idx];
|
||||
if (!r) return;
|
||||
if (r.kind === "page" || r.kind === "client-action") {
|
||||
navigate(r.kind === "page" ? r.href : r.action.href(r.client.id), newTab);
|
||||
navigate(
|
||||
r.kind === "page"
|
||||
? r.href
|
||||
: r.action.href(encodeURIComponent(String(r.client.id || ""))),
|
||||
newTab,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (r.kind === "client") {
|
||||
|
||||
@@ -106,7 +106,8 @@ function tryFit() {
|
||||
function setStatus(label, tone = "pill-offline") {
|
||||
if (!statusPill) return;
|
||||
statusPill.className = `pill ${tone}`;
|
||||
statusPill.innerHTML = `<i class="fa-solid fa-circle"></i> ${label}`;
|
||||
statusPill.innerHTML = '<i class="fa-solid fa-circle"></i>';
|
||||
statusPill.appendChild(document.createTextNode(` ${String(label || "")}`));
|
||||
}
|
||||
|
||||
function writeSystem(msg) {
|
||||
|
||||
@@ -80,5 +80,5 @@ function showAccessDenied(reasons) {
|
||||
function escapeHtml(text) {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = text;
|
||||
return el.innerHTML;
|
||||
return el.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -131,5 +131,5 @@ export function formatBytes(bytes) {
|
||||
export function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function escapeHtml(value) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = value == null ? "" : String(value);
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function formatBytes(bytes, fractionDigits = 2) {
|
||||
|
||||
@@ -342,14 +342,14 @@ function displayFileList(files) {
|
||||
` : ""}
|
||||
<button
|
||||
class="view-log-btn px-3 py-2 rounded-lg border border-blue-700 bg-blue-900/50 hover:bg-blue-800/70 text-blue-100 flex items-center gap-2"
|
||||
data-filename="${file.name}"
|
||||
data-filename="${escapeHtml(file.name)}"
|
||||
>
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
<span>View</span>
|
||||
</button>
|
||||
<button
|
||||
class="download-log-btn px-3 py-2 rounded-lg border border-emerald-700 bg-emerald-900/40 hover:bg-emerald-800/70 text-emerald-100 flex items-center gap-2"
|
||||
data-filename="${file.name}"
|
||||
data-filename="${escapeHtml(file.name)}"
|
||||
>
|
||||
<i class="fa-solid fa-download"></i>
|
||||
<span>Download</span>
|
||||
@@ -357,7 +357,7 @@ function displayFileList(files) {
|
||||
${archiveMode ? "" : `
|
||||
<button
|
||||
class="delete-log-btn px-3 py-2 rounded-lg border border-red-700 bg-red-900/40 hover:bg-red-800/70 text-red-100 flex items-center gap-2"
|
||||
data-filename="${file.name}"
|
||||
data-filename="${escapeHtml(file.name)}"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<span>Delete</span>
|
||||
@@ -428,7 +428,7 @@ function formatDate(dateStr) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text || "";
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
|
||||
@@ -47,7 +47,7 @@ function formatTimestamp(ts) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text || "";
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function parseDetails(details) {
|
||||
|
||||
@@ -1801,7 +1801,7 @@ async function openGroupPicker(clientId) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function renderClientLogs(logs) {
|
||||
|
||||
@@ -533,7 +533,7 @@ function formatBytes(bytes) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : String(text);
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function loadPluginNavItems(activeMap) {
|
||||
if (!plugins || !Array.isArray(plugins)) return;
|
||||
|
||||
const navPlugins = plugins.filter(
|
||||
(p) => p.navbar && p.enabled
|
||||
(p) => p && typeof p === "object" && p.navbar && p.enabled
|
||||
);
|
||||
|
||||
if (navPlugins.length === 0) return;
|
||||
@@ -28,11 +28,17 @@ export async function loadPluginNavItems(activeMap) {
|
||||
if (!childContainer) return;
|
||||
|
||||
const itemsHtml = navPlugins.map((p) => {
|
||||
const linkId = `plugin-nav-${p.id}`;
|
||||
activeMap[`/plugins/${p.id}`] = linkId;
|
||||
const pluginId = String(p.id || "").trim();
|
||||
if (
|
||||
!/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(pluginId) ||
|
||||
pluginId.includes("..") ||
|
||||
pluginId.endsWith(".")
|
||||
) return "";
|
||||
const linkId = `plugin-nav-${pluginId}`;
|
||||
activeMap[`/plugins/${pluginId}`] = linkId;
|
||||
|
||||
const childObj = {
|
||||
href: `/plugins/${p.id}`,
|
||||
href: `/plugins/${pluginId}`,
|
||||
label: p.navbar.label || p.name,
|
||||
icon: p.navbar.icon || "fa-cube",
|
||||
iconColor: "text-slate-300",
|
||||
|
||||
@@ -81,16 +81,45 @@ export const NAV_GROUPS = [
|
||||
TOPBAR — dropdown menus
|
||||
────────────────────────────────────────────── */
|
||||
|
||||
function escapeNavHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function safeNavHref(value) {
|
||||
const href = String(value ?? "").trim();
|
||||
if (!href.startsWith("/") || href.startsWith("//") || /[\u0000-\u001f\u007f]/.test(href)) {
|
||||
return "#";
|
||||
}
|
||||
return escapeNavHtml(href);
|
||||
}
|
||||
|
||||
function safeNavId(value) {
|
||||
return String(value ?? "").replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 192);
|
||||
}
|
||||
|
||||
function safeNavClassList(value) {
|
||||
return String(value ?? "")
|
||||
.split(/\s+/)
|
||||
.filter((token) => /^[A-Za-z0-9_-]+$/.test(token))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function dropdownItem(child) {
|
||||
const hiddenCls = child.hidden ? " hidden" : "";
|
||||
const prefetchAttr = child.turboPrefetch === false ? ' data-turbo-prefetch="false"' : "";
|
||||
const badgeHtml = child.hasBadge
|
||||
? `<span id="enrollment-badge" class="nav-dd-badge hidden"></span>`
|
||||
: "";
|
||||
return `<a href="${child.href}" id="${child.linkId}"${prefetchAttr}
|
||||
class="nav-dd-item${hiddenCls}" data-link-id="${child.linkId}">
|
||||
<i class="fa-solid ${child.icon} ${child.iconColor} nav-dd-item-icon"></i>
|
||||
<span>${child.label}</span>
|
||||
const linkId = safeNavId(child.linkId);
|
||||
return `<a href="${safeNavHref(child.href)}" id="${linkId}"${prefetchAttr}
|
||||
class="nav-dd-item${hiddenCls}" data-link-id="${linkId}">
|
||||
<i class="fa-solid ${safeNavClassList(child.icon)} ${safeNavClassList(child.iconColor)} nav-dd-item-icon"></i>
|
||||
<span>${escapeNavHtml(child.label)}</span>
|
||||
${badgeHtml}
|
||||
</a>`;
|
||||
}
|
||||
@@ -241,10 +270,12 @@ export function sidebarChild(child) {
|
||||
const badgeHtml = child.hasBadge
|
||||
? `<span id="enrollment-badge" class="sb-badge hidden"></span>`
|
||||
: "";
|
||||
return `<a href="${child.href}" id="${child.linkId}"
|
||||
class="sb-link sb-link-child${hiddenCls}" data-link-id="${child.linkId}" title="${child.label}">
|
||||
<i class="fa-solid ${child.icon} ${child.iconColor} sb-icon"></i>
|
||||
<span class="sb-text">${child.label}</span>
|
||||
const linkId = safeNavId(child.linkId);
|
||||
const label = escapeNavHtml(child.label);
|
||||
return `<a href="${safeNavHref(child.href)}" id="${linkId}"
|
||||
class="sb-link sb-link-child${hiddenCls}" data-link-id="${linkId}" title="${label}">
|
||||
<i class="fa-solid ${safeNavClassList(child.icon)} ${safeNavClassList(child.iconColor)} sb-icon"></i>
|
||||
<span class="sb-text">${label}</span>
|
||||
${badgeHtml}
|
||||
</a>`;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ const previewModalImg = document.getElementById("notification-preview-image");
|
||||
const previewModalClose = document.getElementById("notification-preview-close");
|
||||
|
||||
const MAX_ROWS = 200;
|
||||
const MAX_CACHED_PREVIEW_OBJECT_URLS = 8;
|
||||
const previewObjectUrls = new Map();
|
||||
|
||||
// Defaults loaded from /api/notifications/my-settings
|
||||
let defaultWebhookTemplate = "";
|
||||
@@ -225,7 +227,7 @@ function formatTime(ts) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : String(text);
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function wireToggle(inputEl, btnEl) {
|
||||
@@ -459,26 +461,50 @@ function previewFormatter(cell) {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
mountPreviewLoader(wrapper, item, row.isLive);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function rememberPreviewObjectUrl(notificationId, objectUrl) {
|
||||
const existing = previewObjectUrls.get(notificationId);
|
||||
if (existing && existing !== objectUrl) URL.revokeObjectURL(existing);
|
||||
previewObjectUrls.delete(notificationId);
|
||||
previewObjectUrls.set(notificationId, objectUrl);
|
||||
while (previewObjectUrls.size > MAX_CACHED_PREVIEW_OBJECT_URLS) {
|
||||
const oldestId = previewObjectUrls.keys().next().value;
|
||||
const oldestUrl = previewObjectUrls.get(oldestId);
|
||||
previewObjectUrls.delete(oldestId);
|
||||
if (oldestUrl) URL.revokeObjectURL(oldestUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function mountPreviewLoader(wrapper, item, isLive) {
|
||||
const notificationId = item?.id || "";
|
||||
const skipFetch = !notificationId || (!row.isLive && !item?.screenshotId);
|
||||
const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
|
||||
if (skipFetch) {
|
||||
wrapper.textContent = "-";
|
||||
wrapper.className = "text-slate-500";
|
||||
return wrapper;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item._previewObjectUrl) {
|
||||
const img = createPreviewImage(item._previewObjectUrl);
|
||||
wrapper.appendChild(img);
|
||||
return wrapper;
|
||||
const cachedUrl = previewObjectUrls.get(notificationId);
|
||||
if (cachedUrl) {
|
||||
wrapper.appendChild(createPreviewImage(cachedUrl));
|
||||
return;
|
||||
}
|
||||
|
||||
wrapper.textContent = "Loading...";
|
||||
wrapper.className = "text-slate-500";
|
||||
const loadButton = document.createElement("button");
|
||||
loadButton.type = "button";
|
||||
loadButton.className = "text-xs px-2 py-1 rounded border border-slate-700 bg-slate-900/80 hover:bg-slate-800 text-slate-300";
|
||||
loadButton.textContent = "View image";
|
||||
wrapper.appendChild(loadButton);
|
||||
let attempts = 0;
|
||||
const maxAttempts = 5;
|
||||
const fetchPreview = async () => {
|
||||
if (!wrapper.isConnected) return;
|
||||
attempts += 1;
|
||||
loadButton.disabled = true;
|
||||
loadButton.textContent = attempts > 1 ? `Retrying (${attempts}/${maxAttempts})...` : "Loading...";
|
||||
try {
|
||||
const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
@@ -493,7 +519,7 @@ function previewFormatter(cell) {
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
item._previewObjectUrl = objectUrl;
|
||||
rememberPreviewObjectUrl(notificationId, objectUrl);
|
||||
wrapper.innerHTML = "";
|
||||
wrapper.className = "";
|
||||
wrapper.appendChild(createPreviewImage(objectUrl));
|
||||
@@ -506,8 +532,7 @@ function previewFormatter(cell) {
|
||||
wrapper.className = "text-slate-500";
|
||||
}
|
||||
};
|
||||
fetchPreview();
|
||||
return wrapper;
|
||||
loadButton.addEventListener("click", fetchPreview, { once: true });
|
||||
}
|
||||
|
||||
function createPreviewImage(objectUrl) {
|
||||
@@ -524,7 +549,6 @@ function createPreviewImage(objectUrl) {
|
||||
previewModal.classList.remove("hidden");
|
||||
previewModal.classList.add("flex");
|
||||
});
|
||||
if (img.decode) img.decode().catch(() => {});
|
||||
return img;
|
||||
}
|
||||
|
||||
@@ -549,67 +573,7 @@ function createNotificationRowElement(item, isLive) {
|
||||
|
||||
const preview = row.querySelector(".preview-slot");
|
||||
if (preview) {
|
||||
const notificationId = item?.id || "";
|
||||
const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
|
||||
if (skipFetch) {
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
} else {
|
||||
preview.textContent = "Loading...";
|
||||
preview.className = "text-slate-500";
|
||||
const img = document.createElement("img");
|
||||
img.className = "max-h-32 w-auto rounded border border-slate-800/80 cursor-zoom-in";
|
||||
img.loading = "lazy";
|
||||
img.alt = "Notification screenshot";
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 5;
|
||||
const fetchPreview = async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
if ((res.status === 202 || res.status === 404) && attempts < maxAttempts) {
|
||||
setTimeout(fetchPreview, 1000 * attempts);
|
||||
return;
|
||||
}
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
img.classList.add("opacity-0");
|
||||
preview.innerHTML = "";
|
||||
preview.className = "";
|
||||
preview.appendChild(img);
|
||||
img.addEventListener("load", () => img.classList.remove("opacity-0"));
|
||||
img.addEventListener("error", () => {
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
img.src = objectUrl;
|
||||
img.dataset.previewUrl = objectUrl;
|
||||
if (img.decode) img.decode().catch(() => {});
|
||||
img.addEventListener("click", () => {
|
||||
if (!previewModal || !previewModalImg) return;
|
||||
previewModalImg.src = img.dataset.previewUrl || objectUrl;
|
||||
previewModal.classList.remove("hidden");
|
||||
previewModal.classList.add("flex");
|
||||
});
|
||||
} catch {
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(fetchPreview, 1000 * attempts);
|
||||
return;
|
||||
}
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
}
|
||||
};
|
||||
fetchPreview();
|
||||
}
|
||||
mountPreviewLoader(preview, item, isLive);
|
||||
}
|
||||
|
||||
return row;
|
||||
@@ -967,6 +931,10 @@ const clearIfActive = () => {
|
||||
};
|
||||
document.addEventListener("visibilitychange", clearIfActive);
|
||||
window.addEventListener("focus", clearIfActive);
|
||||
window.addEventListener("beforeunload", () => {
|
||||
for (const objectUrl of previewObjectUrls.values()) URL.revokeObjectURL(objectUrl);
|
||||
previewObjectUrls.clear();
|
||||
});
|
||||
clearIfActive();
|
||||
|
||||
function updateDesktopPermissionUi() {
|
||||
|
||||
@@ -362,7 +362,8 @@ function escapeHtml(value) {
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function setPluginEnabled(plugin, enabled, confirmed = false) {
|
||||
@@ -441,8 +442,8 @@ function showPluginEnableConfirmModal(plugin, sigInfo) {
|
||||
<h3 class="text-lg font-semibold text-slate-100">Enable Unverified Plugin</h3>
|
||||
</div>
|
||||
<p class="text-sm text-slate-300 mb-2">${statusText}</p>
|
||||
<p class="text-sm text-slate-400 mb-1">Plugin: <strong class="text-slate-200">${plugin.name || plugin.id}</strong></p>
|
||||
${sig.fingerprint ? `<p class="text-xs text-slate-500 font-mono mb-3">Signer: ${sig.fingerprint}</p>` : '<p class="text-xs text-slate-500 mb-3">No signature present.</p>'}
|
||||
<p class="text-sm text-slate-400 mb-1">Plugin: <strong class="text-slate-200">${escapeHtml(plugin.name || plugin.id)}</strong></p>
|
||||
${sig.fingerprint ? `<p class="text-xs text-slate-500 font-mono mb-3">Signer: ${escapeHtml(sig.fingerprint)}</p>` : '<p class="text-xs text-slate-500 mb-3">No signature present.</p>'}
|
||||
<p class="text-sm text-slate-400" style="margin-bottom: 16px;">Type <strong class="text-emerald-300">confirm</strong> below to enable this plugin:</p>
|
||||
<input type="text" id="plugin-enable-confirm-input" class="w-full px-3 py-2 rounded-lg bg-slate-800 border border-slate-600 text-slate-100 text-sm focus:outline-none focus:border-emerald-500" style="margin-bottom: 16px;" placeholder="Type confirm" autocomplete="off" spellcheck="false" />
|
||||
<div class="plugin-modal-actions flex justify-end gap-2">
|
||||
|
||||
@@ -75,7 +75,8 @@ function updateStatus(state, text) {
|
||||
disconnected: '<i class="fa-solid fa-circle text-slate-500"></i>',
|
||||
};
|
||||
|
||||
statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`;
|
||||
statusEl.innerHTML = icons[state] || icons.disconnected;
|
||||
statusEl.appendChild(document.createTextNode(` ${String(text || "")}`));
|
||||
const stateClasses = {
|
||||
connected: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30",
|
||||
error: "bg-red-500/10 text-red-300 border-red-500/30",
|
||||
@@ -491,7 +492,7 @@ function formatBytes(bytes) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Context menu
|
||||
|
||||
@@ -310,9 +310,12 @@ function actionButtons(c) {
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = String(s ?? "");
|
||||
return d.innerHTML;
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function dedupeGpu(raw) {
|
||||
|
||||
@@ -852,7 +852,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
error: '<i class="fa-solid fa-circle-exclamation text-rose-400"></i>',
|
||||
};
|
||||
const label = streamStatusLabel;
|
||||
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
|
||||
statusEl.innerHTML = icons[state] || icons.idle;
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.textContent = String(label || "");
|
||||
statusEl.appendChild(labelEl);
|
||||
const base = "inline-flex items-center gap-2 px-3 py-2 rounded-full border text-sm";
|
||||
const styles = {
|
||||
streaming: "bg-emerald-900/40 text-emerald-100 border-emerald-700/70",
|
||||
@@ -1606,12 +1609,12 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
accessibility: "Accessibility",
|
||||
fullDiskAccess: "Full Disk Access",
|
||||
};
|
||||
const list = missing.map(k => labels[k] || k).join(", ");
|
||||
const list = missing.map((key) => labels[String(key)] || String(key)).join(", ");
|
||||
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-triangle-exclamation text-amber-400"></i>
|
||||
<span><strong>macOS permissions missing:</strong> ${list}</span>
|
||||
<span><strong>macOS permissions missing:</strong> <span class="rd-elevate-missing"></span></span>
|
||||
</div>
|
||||
<div class="text-xs text-amber-300/80">
|
||||
The client needs elevated privileges to grant these permissions. Enter the user's password to elevate.
|
||||
@@ -1626,6 +1629,8 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
</div>
|
||||
<div id="rdElevateStatus" class="text-xs text-slate-400 hidden"></div>
|
||||
`;
|
||||
const missingEl = banner.querySelector(".rd-elevate-missing");
|
||||
if (missingEl) missingEl.textContent = list;
|
||||
|
||||
// Insert banner above the canvas area
|
||||
if (canvasContainer && canvasContainer.parentNode) {
|
||||
|
||||
@@ -44,7 +44,7 @@ if (typeof document !== "undefined" && typeof MutationObserver !== "undefined")
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const ROW_SELECTOR = "[data-client-row]";
|
||||
@@ -699,12 +699,14 @@ export function createRenderer({
|
||||
return dashboardBadges(client)
|
||||
.sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0))
|
||||
.map((badge) => {
|
||||
const label = String(badge.label || badge.title || badge.pluginId || "Plugin");
|
||||
const title = String(badge.title || label);
|
||||
const label = String(badge.label || badge.title || badge.pluginId || "Plugin").slice(0, 256);
|
||||
const title = String(badge.title || label).slice(0, 512);
|
||||
const tone = String(badge.tone || "info").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "info";
|
||||
const icon = String(badge.icon || "").replace(/[^a-z0-9_\- ]/gi, "").trim();
|
||||
const imageUrl = String(badge.imageUrl || "").trim();
|
||||
const href = String(badge.href || "").trim();
|
||||
const rawImageUrl = String(badge.imageUrl || "").trim().slice(0, 2_048);
|
||||
const rawHref = String(badge.href || "").trim().slice(0, 2_048);
|
||||
const imageUrl = /^(?:https?:\/\/|\/(?!\/))/i.test(rawImageUrl) ? rawImageUrl : "";
|
||||
const href = /^(?:https?:\/\/|\/(?!\/)|#)/i.test(rawHref) ? rawHref : "";
|
||||
const visual = imageUrl
|
||||
? `<img src="${escapeHtml(imageUrl)}" alt="">`
|
||||
: `<i class="${escapeHtml(icon || "fa-solid fa-puzzle-piece")}"></i>`;
|
||||
|
||||
@@ -238,7 +238,7 @@ function updateSelectedCount() {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
let cachedSavedScripts = [];
|
||||
|
||||
@@ -794,6 +794,36 @@ async function loadMfaStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderMfaQrCode(svgMarkup) {
|
||||
if (!mfaQrCode) return;
|
||||
mfaQrCode.replaceChildren();
|
||||
|
||||
const svg = typeof svgMarkup === "string" ? svgMarkup.trim() : "";
|
||||
if (!svg || svg.length > 512 * 1024 || !/^<svg[\s>]/i.test(svg) || !/<\/svg>$/i.test(svg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep API-provided SVG out of the document DOM. SVG loaded through an
|
||||
// image element is an inert image context, so scripts and event handlers in
|
||||
// a compromised response cannot run in the settings page.
|
||||
const image = document.createElement("img");
|
||||
image.alt = "MFA setup QR code";
|
||||
image.className = "block h-full w-full";
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
mfaQrCode.appendChild(image);
|
||||
}
|
||||
|
||||
function safeMfaOtpauthUrl(value) {
|
||||
const candidate = typeof value === "string" ? value.trim() : "";
|
||||
if (!candidate || candidate.length > 4096) return "#";
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
return parsed.protocol === "otpauth:" && parsed.hostname === "totp" ? parsed.href : "#";
|
||||
} catch {
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
async function startMfaSetup() {
|
||||
try {
|
||||
const res = await fetch("/api/mfa/setup", { method: "POST", credentials: "include" });
|
||||
@@ -802,9 +832,9 @@ async function startMfaSetup() {
|
||||
showMfaMessage(data.error || "Failed to start MFA setup.", "error");
|
||||
return;
|
||||
}
|
||||
if (mfaQrCode) mfaQrCode.innerHTML = data.qrSvg || "";
|
||||
renderMfaQrCode(data.qrSvg);
|
||||
if (mfaSecretText) mfaSecretText.textContent = data.secret || "";
|
||||
if (mfaOtpauthLink) mfaOtpauthLink.href = data.otpauthUrl || "#";
|
||||
if (mfaOtpauthLink) mfaOtpauthLink.setAttribute("href", safeMfaOtpauthUrl(data.otpauthUrl));
|
||||
revealElement(mfaSetupPanel, { duration: 240, offset: 8 });
|
||||
if (mfaEnableCodeInput) mfaEnableCodeInput.focus();
|
||||
} catch {
|
||||
|
||||
@@ -112,7 +112,7 @@ function renderTable(proxies) {
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -117,7 +117,7 @@ function renderRpcEndpointManager(records) {
|
||||
function escapeHtml(value) {
|
||||
const element = document.createElement("span");
|
||||
element.textContent = String(value);
|
||||
return element.innerHTML;
|
||||
return element.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
testAllRpcBtn.addEventListener("click", async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ function showMessage(text, type = "ok") {
|
||||
function escapeHtml(value) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = value == null ? "" : String(value);
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function getRule(clientId) {
|
||||
|
||||
@@ -115,7 +115,7 @@ let dragGhost = null;
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function findBlockDef(blockId) {
|
||||
|
||||
@@ -68,7 +68,10 @@ function setStatus(text, cls) {
|
||||
if (!statusPill) return;
|
||||
statusPill.className =
|
||||
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border " + cls;
|
||||
statusPill.innerHTML = `<i class="fa-solid fa-circle"></i><span>${text}</span>`;
|
||||
statusPill.innerHTML = '<i class="fa-solid fa-circle"></i>';
|
||||
const label = document.createElement("span");
|
||||
label.textContent = String(text || "");
|
||||
statusPill.appendChild(label);
|
||||
}
|
||||
|
||||
function downsampleTo16k(float32Buffer, sampleRate) {
|
||||
@@ -537,7 +540,10 @@ function daSetStatus(text, cls) {
|
||||
if (!daStatusPill) return;
|
||||
daStatusPill.className =
|
||||
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border " + cls;
|
||||
daStatusPill.innerHTML = `<i class="fa-solid fa-circle"></i><span>${text}</span>`;
|
||||
daStatusPill.innerHTML = '<i class="fa-solid fa-circle"></i>';
|
||||
const label = document.createElement("span");
|
||||
label.textContent = String(text || "");
|
||||
daStatusPill.appendChild(label);
|
||||
}
|
||||
|
||||
function daClearPlayback() {
|
||||
|
||||
@@ -523,7 +523,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
state === "error" ? "Error" :
|
||||
"Stopped");
|
||||
|
||||
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
|
||||
statusEl.innerHTML = icons[state] || icons.idle;
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.textContent = String(label || "");
|
||||
statusEl.appendChild(labelEl);
|
||||
|
||||
const wsOpen = ws && ws.readyState === WebSocket.OPEN;
|
||||
const streamLocked = streamState === "streaming" || streamState === "starting" || streamState === "stopping";
|
||||
|
||||
@@ -211,7 +211,7 @@ function formatBytes(bytes) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
clientSearch.addEventListener("input", filterAndRenderClients);
|
||||
|
||||
Reference in New Issue
Block a user