Files
lpra/internal/server/web/templates/files.html
T
redtea 08fc2d79a8 fix: take the file edit limit in the panel from the agent's read cap
The limit was repeated in the page; a file of exactly the cap is read
in full, so it is editable too.
2026-10-13 14:50:49 +03:00

288 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} — LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
/* Normalize buttons and the file <label> button so they line up on one row. */
.fm-bar .btn, .fm-bar button, .fm-bar label.btn {
display:inline-flex; align-items:center; justify-content:center;
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
}
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
border-radius:50%; animation:spin .7s linear infinite; display:none; }
@keyframes spin { to { transform:rotate(360deg); } }
#drop { border:1px dashed var(--border); border-radius:10px; }
#drop.drag { border-color:var(--accent); background:#12202f; }
table.fm td, table.fm th { padding:7px 8px; }
.fm .nm { cursor:pointer; }
.fm .nm:hover { color:var(--accent); }
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; }
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:12px; padding:0; max-width:900px; width:92vw; }
dialog::backdrop { background:rgba(0,0,0,.6); }
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
.dlg-b { padding:16px 18px; }
textarea#edit { width:100%; height:52vh; background:#0c0f13; color:var(--text);
border:1px solid var(--border); border-radius:8px; padding:12px;
font-family:ui-monospace,Menlo,monospace; font-size:13px; }
.toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
background:#2a2f3a; border:1px solid var(--border); border-radius:8px;
padding:10px 16px; font-size:14px; opacity:0; transition:opacity .2s; pointer-events:none; }
.toast.show { opacity:1; }
.toast.err { background:#3a1d1d; border-color:#6b2b2b; color:#ffb3b3; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand">LPRA · Файлы — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
</nav>
</header>
<div class="wrap" style="max-width:1100px">
<div class="fm-bar">
<button class="btn ghost small" id="up">↑ Вверх</button>
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
<span class="spinner" id="spin"></span>
<button class="btn ghost small" id="refresh">Обновить</button>
</div>
<div class="fm-bar">
<button class="btn small" id="mkdir">+ Папка</button>
<button class="btn small" id="newfile">+ Файл</button>
<label class="btn small" style="cursor:pointer">+ Загрузить<input type="file" id="upl" multiple hidden></label>
<span class="spacer" style="flex:1"></span>
<span id="selinfo" class="muted" style="font-size:13px"></span>
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
</div>
<div class="card" id="drop" style="padding:0">
<table class="fm">
<thead><tr>
<th style="width:28px"><input type="checkbox" id="all"></th>
<th>Имя</th><th style="width:120px" class="sz">Размер</th>
<th style="width:160px" class="sz">Изменён</th><th style="width:160px"></th>
</tr></thead>
<tbody id="list"></tbody>
</table>
</div>
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу — просмотр (правка, если &lt; 2 КБ).</p>
</div>
<dialog id="viewer">
<div class="dlg-h">
<strong id="vname" style="flex:1; overflow:hidden; text-overflow:ellipsis"></strong>
<button class="btn small" id="save" style="display:none">Сохранить</button>
<button class="btn ghost small" id="closev">Закрыть</button>
</div>
<div class="dlg-b">
<div id="vmsg" class="muted" style="margin-bottom:8px; font-size:13px"></div>
<textarea id="edit" spellcheck="false"></textarea>
</div>
</dialog>
<div class="toast" id="toast"></div>
<script>
(function () {
const agentID = {{.Agent.ID}};
let busyN = 0;
function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('spin').style.display = busyN > 0 ? 'inline-block' : 'none'; }
const api = (op, body) => {
setBusy(true);
return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body || {})
}).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false));
};
const EDIT_LIMIT = {{.EditLimit}};
// Monochrome inline icons (inherit button color via currentColor).
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
let cwd = '/';
let entries = [];
const selected = new Set();
const $ = id => document.getElementById(id);
const toast = (msg, err) => { const t=$('toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',2200); };
const b64ToBytes = b64 => { const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u; };
const bytesToB64 = bytes => { let s=''; const CH=0x8000; for(let i=0;i<bytes.length;i+=CH) s+=String.fromCharCode.apply(null, bytes.subarray(i,i+CH)); return btoa(s); };
const join = (dir, name) => (dir.endsWith('/') ? dir : dir+'/') + name;
const parent = p => { if(p==='/'||p==='') return '/'; const i=p.replace(/\/+$/,'').lastIndexOf('/'); return i<=0 ? '/' : p.slice(0,i); };
const fmtSize = n => { if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' K'; if(n<1073741824)return (n/1048576).toFixed(1)+' M'; return (n/1073741824).toFixed(1)+' G'; };
const fmtTime = t => t ? new Date(t*1000).toLocaleString() : '';
async function load(path) {
const r = await api('list', {path});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
$('path').value = cwd;
render();
}
// Editable path: Enter navigates, or reports a missing directory.
async function navTo(p) {
p = (p || '').trim() || '/';
const r = await api('list', {path: p});
if (!r.ok) { toast(r.error || 'нет такого каталога', true); $('path').value = cwd; return; }
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
$('path').value = cwd; render();
}
function render() {
const tb = $('list'); tb.innerHTML = '';
updateSel();
for (const e of entries) {
const tr = document.createElement('tr');
const isZip = !e.is_dir && /\.zip$/i.test(e.name);
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
tr.innerHTML =
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
'<td class="sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
'<td class="sz">'+fmtTime(e.mtime)+'</td>' +
'<td><div class="act">' +
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
'<button class="btn ghost small icon" data-act="ren" title="Переместить / переименовать">'+ICON_MV+'</button>' +
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
'</div></td>';
const nm = tr.querySelector('.nm');
nm.onclick = () => e.is_dir ? load(join(cwd, e.name)) : view(e);
tr.querySelector('.ck').onchange = ev => { const n=e.name; ev.target.checked?selected.add(n):selected.delete(n); updateSel(); };
tr.querySelector('[data-act="del"]').onclick = () => del(e);
tr.querySelector('[data-act="ren"]').onclick = () => move(e);
const uz = tr.querySelector('[data-act="unzip"]'); if (uz) uz.onclick = () => unzip(e);
tb.appendChild(tr);
}
}
const esc = s => s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function updateSel() {
const n = selected.size;
$('selinfo').textContent = n ? ('выбрано: '+n) : '';
$('zipsel').disabled = !n; $('delsel').disabled = !n;
}
async function view(e) {
const r = await api('read', {path: join(cwd, e.name)});
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
const d = r.data;
$('vname').textContent = e.name;
const ta = $('edit'); const save = $('save'); const msg = $('vmsg');
if (d.binary) {
ta.value = '(бинарный файл, просмотр недоступен)'; ta.readOnly = true; save.style.display='none';
msg.textContent = 'Размер: '+fmtSize(d.size);
} else {
ta.value = new TextDecoder().decode(b64ToBytes(d.content));
const editable = d.size <= EDIT_LIMIT && !d.truncated;
ta.readOnly = !editable;
save.style.display = editable ? '' : 'none';
msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые '+fmtSize(EDIT_LIMIT):'') + (editable?' · редактируемо':' · только чтение (больше '+fmtSize(EDIT_LIMIT)+')');
save.onclick = async () => {
const bytes = new TextEncoder().encode(ta.value);
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
if (w.ok) { toast('сохранено'); $('viewer').close(); load(cwd); } else toast(w.error||'ошибка', true);
};
}
$('viewer').showModal();
}
$('closev').onclick = () => $('viewer').close();
async function del(e) {
if (e.is_dir) {
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
const r = await api('deltree', {path: join(cwd, e.name)});
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
} else {
if (!confirm('Удалить файл "'+e.name+'"?')) return;
const r = await api('delete', {path: join(cwd, e.name)});
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
}
}
async function move(e) {
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name));
if (!dst) return;
const r = await api('move', {src: join(cwd, e.name), dst});
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true);
}
async function unzip(e) {
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
r.ok ? (toast('распаковано'), load(cwd)) : toast(r.error||'ошибка', true);
}
$('mkdir').onclick = async () => {
const n = prompt('Имя новой папки:'); if (!n) return;
const r = await api('mkdir', {path: join(cwd, n)});
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
};
$('newfile').onclick = async () => {
const n = prompt('Имя нового файла:'); if (!n) return;
const r = await api('write', {path: join(cwd, n), data: ''});
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
};
$('delsel').onclick = async () => {
if (!confirm('Удалить выбранное ('+selected.size+')?')) return;
for (const n of selected) {
const e = entries.find(x => x.name === n);
await api(e && e.is_dir ? 'deltree' : 'delete', {path: join(cwd, n)});
}
toast('удалено'); load(cwd);
};
$('zipsel').onclick = async () => {
const name = prompt('Имя архива:', 'archive.zip'); if (!name) return;
const paths = [...selected].map(n => join(cwd, n));
const r = await api('zip', {paths, dest: join(cwd, name)});
r.ok ? (toast('архив создан'), load(cwd)) : toast(r.error||'ошибка', true);
};
$('all').onchange = ev => {
selected.clear();
if (ev.target.checked) entries.forEach(e => selected.add(e.name));
document.querySelectorAll('.ck').forEach(c => c.checked = ev.target.checked);
updateSel();
};
$('up').onclick = () => load(parent(cwd));
$('refresh').onclick = () => load(cwd);
$('path').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); navTo($('path').value); }
});
// uploads
async function uploadFiles(files) {
for (const f of files) {
if (f.size > 32*1024*1024) { toast('файл '+f.name+' больше 32 МБ', true); continue; }
const buf = new Uint8Array(await f.arrayBuffer());
const r = await api('write', {path: join(cwd, f.name), data: bytesToB64(buf)});
if (!r.ok) { toast(r.error||('ошибка '+f.name), true); return; }
}
toast('загружено'); load(cwd);
}
$('upl').onchange = ev => uploadFiles(ev.target.files);
const drop = $('drop');
['dragenter','dragover'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); drop.classList.add('drag'); }));
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
load('/');
})();
</script>
</body>
</html>