298 lines
16 KiB
HTML
298 lines
16 KiB
HTML
<!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>
|
||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</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">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</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 => ({'&':'&','<':'<','>':'>','"':'"'}[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); });
|
||
|
||
// Start in the home directory of the machine's user; an agent too old to
|
||
// know "~" fails that listing, so fall back to the root.
|
||
(async () => {
|
||
const r = await api('list', {path: '~' + {{.Agent.LoginUser}}});
|
||
if (!r.ok) { load('/'); return; }
|
||
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||
$('path').value = cwd;
|
||
render();
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|