2332 lines
148 KiB
JavaScript
2332 lines
148 KiB
JavaScript
/* global Terminal, FitAddon, WebLinksAddon, SearchAddon, TERM_THEMES */
|
||
'use strict';
|
||
|
||
// ============================================================ Helpers
|
||
const $ = (sel, root = document) => root.querySelector(sel);
|
||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
const uid = () => crypto.randomUUID();
|
||
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
||
const T = I18N.t;
|
||
|
||
const ICONS = {
|
||
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>',
|
||
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
|
||
edit: '<svg viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z"/></svg>',
|
||
trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
|
||
more: '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg>',
|
||
close: '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>',
|
||
folder: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
||
file: '<svg viewBox="0 0 24 24"><path d="M6 3h8l4 4v14H6z M14 3v4h4"/></svg>',
|
||
term: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3M12 15h5"/></svg>',
|
||
screen: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>',
|
||
key: '<svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg>',
|
||
copy: '<svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>',
|
||
play: '<svg viewBox="0 0 24 24"><path d="M7 5l12 7-12 7z"/></svg>',
|
||
stop: '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>',
|
||
up: '<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
|
||
refresh: '<svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6"/></svg>',
|
||
home: '<svg viewBox="0 0 24 24"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
|
||
newdir: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6"/></svg>',
|
||
arrowR: '<svg viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',
|
||
arrowL: '<svg viewBox="0 0 24 24"><path d="M19 12H5M11 6l-6 6 6 6"/></svg>',
|
||
code: '<svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg>',
|
||
shield: '<svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/></svg>',
|
||
lock: '<svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>',
|
||
unlock: '<svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.5-2"/></svg>',
|
||
eye: '<svg viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>',
|
||
fwd: '<svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg>',
|
||
dup: '<svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V5a1 1 0 0 1 1-1h11"/></svg>',
|
||
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
||
download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>',
|
||
upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>',
|
||
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
|
||
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
|
||
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>',
|
||
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
|
||
};
|
||
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||
function colorFor(h) {
|
||
if (h.color) return h.color;
|
||
let n = 0; for (const c of h.label || h.address || '') n = (n * 31 + c.charCodeAt(0)) >>> 0;
|
||
return COLORS[n % COLORS.length];
|
||
}
|
||
function avatar(hst, size) {
|
||
const letter = esc((hst.label || hst.address || '?').trim()[0]?.toUpperCase() || '?');
|
||
const proto = hst.protocol === 'rdp' ? 'RDP' : 'SSH';
|
||
return `<div class="avatar" style="background:${colorFor(hst)}${size ? `;width:${size}px;height:${size}px` : ''}">${hst.protocol === 'rdp' ? ICONS.screen : letter}<span class="proto">${proto}</span></div>`;
|
||
}
|
||
function fmtSize(n) {
|
||
if (n == null) return '';
|
||
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
|
||
}
|
||
const fmtDate = (t) => (t ? new Date(t).toLocaleString(I18N.lang, { dateStyle: 'short', timeStyle: 'short' }) : '');
|
||
function relTime(t) {
|
||
const s = (Date.now() - t) / 1000;
|
||
if (s < 60) return T('just now');
|
||
if (s < 3600) return T('{n} min ago', { n: Math.floor(s / 60) });
|
||
if (s < 86400) return T('{n} h ago', { n: Math.floor(s / 3600) });
|
||
return T('{n} days ago', { n: Math.floor(s / 86400) });
|
||
}
|
||
|
||
function toast(msg, type = '') {
|
||
const el = h(`<div class="toast ${type}">${esc(msg)}</div>`);
|
||
$('#toasts').append(el);
|
||
setTimeout(() => el.remove(), type === 'error' ? 7000 : 3500);
|
||
}
|
||
|
||
async function call(ch, ...a) {
|
||
try { return await api.call(ch, ...a); }
|
||
catch (e) { toast(e.message, 'error'); throw e; }
|
||
}
|
||
|
||
// ---------- Modals
|
||
function modal({ title, text = '', body = '', buttons = [] }) {
|
||
return new Promise((resolve) => {
|
||
const bg = h(`<div class="modal-bg"><div class="modal"><div class="mbody"><h3>${esc(title)}</h3>${text ? `<p>${esc(text)}</p>` : ''}${body}</div><div class="mfoot"></div></div></div>`);
|
||
const foot = $('.mfoot', bg);
|
||
const done = (v) => { bg.remove(); resolve(v); };
|
||
buttons.forEach((b) => {
|
||
const btn = h(`<button class="btn ${b.cls || ''}">${esc(b.label)}</button>`);
|
||
btn.onclick = () => done(b.value === 'form' ? Object.fromEntries($$('[name]', bg).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value])) : b.value);
|
||
foot.append(btn);
|
||
});
|
||
bg.addEventListener('mousedown', (e) => { if (e.target === bg) done(null); });
|
||
bg.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') done(null);
|
||
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); foot.lastElementChild.click(); }
|
||
});
|
||
$('#modalRoot').append(bg);
|
||
($('input,select,textarea', bg) || foot.lastElementChild).focus();
|
||
});
|
||
}
|
||
const confirmBox = (title, text, label = T('Delete')) =>
|
||
modal({ title, text, buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label, value: true, cls: label === T('Delete') ? 'danger' : 'primary' }] });
|
||
async function promptBox(title, label, value = '', type = 'text') {
|
||
const r = await modal({ title, body: `<div class="field"><label>${esc(label)}</label><input name="v" type="${type}" value="${esc(value)}" /></div>`,
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('OK'), value: 'form', cls: 'primary' }] });
|
||
return r ? r.v : null;
|
||
}
|
||
|
||
// ---------- Kontextmenü
|
||
function ctxMenu(x, y, items) {
|
||
$$('.ctx').forEach((c) => c.remove());
|
||
const m = h('<div class="ctx"></div>');
|
||
for (const it of items) {
|
||
if (it === '-') { m.append(h('<hr>')); continue; }
|
||
const b = h(`<button class="${it.danger ? 'danger' : ''}">${it.icon ? ICONS[it.icon] : ''}<span>${esc(it.label)}</span></button>`);
|
||
b.onclick = () => { m.remove(); it.run(); };
|
||
m.append(b);
|
||
}
|
||
document.body.append(m);
|
||
const r = m.getBoundingClientRect();
|
||
m.style.left = Math.min(x, innerWidth - r.width - 8) + 'px';
|
||
m.style.top = Math.min(y, innerHeight - r.height - 8) + 'px';
|
||
setTimeout(() => document.addEventListener('mousedown', function off(e) { if (!m.contains(e.target)) { m.remove(); document.removeEventListener('mousedown', off); } }), 0);
|
||
}
|
||
|
||
// ============================================================ State
|
||
const S = {
|
||
vault: null,
|
||
view: 'hosts',
|
||
group: null,
|
||
search: '',
|
||
tag: null,
|
||
tabs: [],
|
||
active: 'home',
|
||
activeForwards: new Set(),
|
||
};
|
||
const settings = () => S.vault.settings;
|
||
|
||
async function reload() {
|
||
S.lock = await call('lock:status');
|
||
$('#lockBtn').style.display = S.lock.enabled ? '' : 'none';
|
||
if (S.lock.locked) return showLock();
|
||
S.vault = await call('vault:get');
|
||
I18N.setLanguage(settings().language, navigator.language);
|
||
applyStatic();
|
||
const b = $('#vaultBadge');
|
||
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`;
|
||
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
|
||
applyAppTheme();
|
||
S.activeForwards = new Set(await call('fw:active'));
|
||
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||
render();
|
||
}
|
||
|
||
// Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title)
|
||
function applyStatic() {
|
||
document.documentElement.lang = I18N.lang;
|
||
$$('[data-i18n]').forEach((el) => { el.textContent = T(el.dataset.i18n); });
|
||
$$('[data-i18n-title]').forEach((el) => { el.title = T(el.dataset.i18nTitle); });
|
||
// Tooltips für die eingeklappte Symbolansicht
|
||
$$('#navMenu .nav').forEach((n) => { n.title = n.querySelector('span')?.textContent || ''; });
|
||
$('#navToggle').title = $('#sidebar').classList.contains('nav-collapsed') ? T('Expand menu') : T('Collapse menu');
|
||
}
|
||
|
||
// ============================================================ Navigation
|
||
$$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => {
|
||
S.view = n.dataset.view;
|
||
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x === n));
|
||
closeDrawer();
|
||
activateTab('home');
|
||
render();
|
||
}));
|
||
|
||
// ============================================================ Einklappbares Menü (Sidebar)
|
||
(() => {
|
||
const KEY = 'mrterm.nav.collapsed';
|
||
const sb = $('#sidebar');
|
||
const apply = (collapsed) => {
|
||
sb.classList.toggle('nav-collapsed', collapsed);
|
||
$('#navToggle').title = collapsed ? T('Expand menu') : T('Collapse menu');
|
||
};
|
||
let collapsed = false;
|
||
try { collapsed = localStorage.getItem(KEY) === '1'; } catch {}
|
||
apply(collapsed);
|
||
$('#navToggle').onclick = () => {
|
||
collapsed = !collapsed;
|
||
apply(collapsed);
|
||
try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch {}
|
||
};
|
||
})();
|
||
|
||
// ============================================================ Verzeichnisbaum (Sidebar)
|
||
const TREE_KEY = 'mrterm.tree.open';
|
||
const treeOpen = new Set((() => { try { return JSON.parse(localStorage.getItem(TREE_KEY) || '[]'); } catch { return []; } })());
|
||
const saveTreeOpen = () => { try { localStorage.setItem(TREE_KEY, JSON.stringify([...treeOpen])); } catch {} };
|
||
const CHEV = '<svg viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>';
|
||
|
||
function goGroup(id) {
|
||
S.view = 'hosts'; S.group = id; S.search = ''; S.tag = null;
|
||
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x.dataset.view === 'hosts'));
|
||
closeDrawer();
|
||
activateTab('home');
|
||
render();
|
||
}
|
||
|
||
async function moveHostToGroup(hostId, groupId) {
|
||
const hst = S.vault.hosts.find((x) => x.id === hostId);
|
||
if (!hst || (hst.groupId || null) === groupId) return;
|
||
await call('vault:upsert', 'hosts', { ...hst, groupId });
|
||
if (groupId) { treeOpen.add(groupId); saveTreeOpen(); }
|
||
reload();
|
||
}
|
||
function dropTarget(el, groupId) {
|
||
el.addEventListener('dragover', (e) => { if (e.dataTransfer.types.includes('mrterm/host')) { e.preventDefault(); el.classList.add('drop'); } });
|
||
el.addEventListener('dragleave', () => el.classList.remove('drop'));
|
||
el.addEventListener('drop', (e) => {
|
||
e.preventDefault(); el.classList.remove('drop');
|
||
const id = e.dataTransfer.getData('mrterm/host');
|
||
if (id) moveHostToGroup(id, groupId);
|
||
});
|
||
}
|
||
|
||
function renderTree() {
|
||
const box = $('#tree');
|
||
if (!box || !S.vault) return;
|
||
const scroll = box.scrollTop;
|
||
box.innerHTML = '';
|
||
const { groups, hosts } = S.vault;
|
||
const byLabel = (x, y) => (x.label || x.address || '').localeCompare(y.label || y.address || '', undefined, { numeric: true });
|
||
const countIn = (gid) => hosts.filter((x) => x.groupId === gid).length + groups.filter((g) => g.parentId === gid).reduce((n, g) => n + countIn(g.id), 0);
|
||
|
||
const head = h(`<div class="tree-head"><span>${T('Connections')}</span><button title="${T('Collapse all')}">${ICONS.up}</button></div>`);
|
||
$('button', head).onclick = () => { treeOpen.clear(); saveTreeOpen(); renderTree(); };
|
||
dropTarget(head, null); // auf die Überschrift ziehen = Stammebene
|
||
box.append(head);
|
||
if (!groups.length && !hosts.length) { box.append(h(`<div class="tree-empty">${T('No hosts yet')}</div>`)); return; }
|
||
|
||
const pad = (depth) => `padding-left:${4 + depth * 14}px`;
|
||
const walk = (parentId, depth) => {
|
||
for (const g of groups.filter((x) => (x.parentId || null) === parentId).sort(byLabel)) {
|
||
const open = treeOpen.has(g.id);
|
||
const active = S.view === 'hosts' && S.group === g.id && S.active === 'home';
|
||
const row = h(`<div class="tree-row group ${active ? 'active' : ''}" style="${pad(depth)}" title="${esc(g.label)}">
|
||
<span class="chev ${open ? 'open' : ''}">${CHEV}</span><span class="ti" style="color:${g.color || 'var(--folder)'}">${ICONS.folder}</span><span class="tl">${esc(g.label)}</span><span class="tc">${countIn(g.id)}</span></div>`);
|
||
const toggle = () => { if (open) treeOpen.delete(g.id); else treeOpen.add(g.id); saveTreeOpen(); renderTree(); };
|
||
$('.chev', row).onclick = (e) => { e.stopPropagation(); toggle(); };
|
||
row.onclick = () => { if (!open) { treeOpen.add(g.id); saveTreeOpen(); } goGroup(g.id); };
|
||
row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||
{ label: T('Open'), icon: 'folder', run: () => goGroup(g.id) },
|
||
{ label: T('Add host'), icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } },
|
||
{ label: T('Create subfolder'), icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } },
|
||
{ label: T('Edit'), icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } },
|
||
'-',
|
||
{ label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } },
|
||
]);
|
||
dropTarget(row, g.id);
|
||
box.append(row);
|
||
if (open) walk(g.id, depth + 1);
|
||
}
|
||
for (const x of hosts.filter((y) => (y.groupId || null) === parentId).sort(byLabel)) {
|
||
const live = S.tabs.some((t) => t.host?.id === x.id);
|
||
const row = h(`<div class="tree-row host" style="${pad(depth)}" draggable="true" title="${esc(hostSub(x))}">
|
||
<span class="chev"></span><span class="ti" style="background:${colorFor(x)}">${x.protocol === 'rdp' ? ICONS.screen : ICONS.term}</span><span class="tl">${esc(x.label || x.address)}</span>${live ? '<span class="tc" style="color:var(--green)">●</span>' : ''}</div>`);
|
||
row.ondblclick = () => connectHost(x);
|
||
row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(x));
|
||
row.ondragstart = (e) => { e.dataTransfer.setData('mrterm/host', x.id); e.dataTransfer.effectAllowed = 'move'; };
|
||
box.append(row);
|
||
}
|
||
};
|
||
walk(null, 0);
|
||
box.scrollTop = scroll;
|
||
}
|
||
|
||
function render() {
|
||
const page = $('#homeView');
|
||
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, vpns: viewVpns, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
|
||
const scroll = page.scrollTop;
|
||
page.innerHTML = '';
|
||
views[S.view](page);
|
||
page.scrollTop = scroll;
|
||
renderTree();
|
||
}
|
||
|
||
function toolbar(placeholder, buttons = []) {
|
||
const t = h(`<div class="toolbar"><label class="search">${ICONS.search}<input placeholder="${esc(placeholder)}" value="${esc(S.search)}" /></label></div>`);
|
||
const inp = $('input', t);
|
||
inp.addEventListener('input', () => { S.search = inp.value; const pos = inp.selectionStart; render(); const n = $('#homeView .search input'); n.focus(); n.setSelectionRange(pos, pos); });
|
||
buttons.forEach((b) => { const el = h(`<button class="btn ${b.cls || ''}">${b.icon ? ICONS[b.icon] : ''}${esc(b.label)}</button>`); el.onclick = b.run; t.append(el); });
|
||
return t;
|
||
}
|
||
const matches = (...fields) => { const q = S.search.trim().toLowerCase(); return !q || fields.some((f) => String(f || '').toLowerCase().includes(q)); };
|
||
function emptyState(icon, title, text, btnLabel, run) {
|
||
const e = h(`<div class="empty"><div class="big">${ICONS[icon]}</div><h3>${esc(title)}</h3><p>${esc(text)}</p></div>`);
|
||
if (btnLabel) { const b = h(`<button class="btn primary">${ICONS.plus}${esc(btnLabel)}</button>`); b.onclick = run; e.append(b); }
|
||
return e;
|
||
}
|
||
|
||
// ============================================================ Hosts
|
||
function hostSub(hst) {
|
||
const u = hst.username ? `${hst.username}@` : '';
|
||
const port = hst.port && Number(hst.port) !== (hst.protocol === 'rdp' ? 3389 : 22) ? `:${hst.port}` : '';
|
||
return `${hst.protocol === 'rdp' ? 'rdp' : 'ssh'}, ${u}${hst.address}${port}`;
|
||
}
|
||
|
||
function viewHosts(page) {
|
||
page.append(toolbar(T('Search hosts or enter user@host …'), [
|
||
{ label: T('Group'), icon: 'newdir', run: () => editGroup({ parentId: S.group }) },
|
||
{ label: T('New host'), icon: 'plus', cls: 'primary', run: () => editHost({ groupId: S.group }) },
|
||
]));
|
||
const c = h('<div class="content"></div>');
|
||
page.append(c);
|
||
const { groups, hosts } = S.vault;
|
||
const searching = !!S.search.trim();
|
||
|
||
if (S.group) {
|
||
const trail = []; let g = groups.find((x) => x.id === S.group);
|
||
while (g) { trail.unshift(g); g = groups.find((x) => x.id === g.parentId); }
|
||
const bc = h('<div class="breadcrumb"></div>');
|
||
const root = h(`<a>${esc(T('All hosts'))}</a>`); root.onclick = () => { S.group = null; render(); }; bc.append(root);
|
||
trail.forEach((t) => { bc.append(' / '); const a = h(`<a>${esc(t.label)}</a>`); a.onclick = () => { S.group = t.id; render(); }; bc.append(a); });
|
||
c.append(bc);
|
||
}
|
||
|
||
const allTags = [...new Set(hosts.flatMap((x) => x.tags || []))].sort();
|
||
if (allTags.length) {
|
||
const ch = h('<div class="chips" style="margin-top:12px"></div>');
|
||
allTags.forEach((t) => { const b = h(`<button class="chip ${S.tag === t ? 'active' : ''}">#${esc(t)}</button>`); b.onclick = () => { S.tag = S.tag === t ? null : t; render(); }; ch.append(b); });
|
||
c.append(ch);
|
||
}
|
||
|
||
const subGroups = searching ? groups.filter((g) => matches(g.label)) : groups.filter((g) => (g.parentId || null) === S.group);
|
||
if (subGroups.length && !S.tag) {
|
||
c.append(h(`<div class="section-title">${T('Groups')} <span class="count">${subGroups.length}</span></div>`));
|
||
const grid = h('<div class="grid"></div>');
|
||
for (const g of subGroups) {
|
||
const count = hosts.filter((x) => x.groupId === g.id).length;
|
||
const card = h(`<div class="card group-card"><div class="avatar" ${g.color ? `style="background:${g.color}22;color:${g.color}"` : ''}>${ICONS.folder}</div><div class="meta"><div class="title">${esc(g.label)}</div><div class="sub">${count} Host${count === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button></div></div>`);
|
||
card.onclick = (e) => { if (e.target.closest('[data-a=edit]')) return editGroup(g); S.group = g.id; S.search = ''; render(); };
|
||
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||
{ label: T('Edit'), icon: 'edit', run: () => editGroup(g) },
|
||
{ label: T('Add host'), icon: 'plus', run: () => editHost({ groupId: g.id }) },
|
||
'-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } },
|
||
]);
|
||
grid.append(card);
|
||
}
|
||
c.append(grid);
|
||
}
|
||
|
||
let list = hosts.filter((x) => (searching || S.tag ? true : (x.groupId || null) === S.group));
|
||
list = list.filter((x) => matches(x.label, x.address, x.username, ...(x.tags || [])));
|
||
if (S.tag) list = list.filter((x) => (x.tags || []).includes(S.tag));
|
||
list.sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
|
||
|
||
if (list.length) {
|
||
c.append(h(`<div class="section-title">Hosts <span class="count">${list.length}</span></div>`));
|
||
const grid = h('<div class="grid"></div>');
|
||
list.forEach((hst) => grid.append(hostCard(hst)));
|
||
c.append(grid);
|
||
} else if (!subGroups.length) {
|
||
const q = S.search.trim();
|
||
if (q && /^[^\s@]+@[^\s]+$|^[\w.-]+(:\d+)?$/.test(q)) {
|
||
c.append(emptyState('term', T('Connect to {q}?', { q }), T('Quick Connect without saving the host.'), T('Connect'), () => quickConnect(q)));
|
||
} else if (!hosts.length) {
|
||
const e = emptyState('screen', T('No hosts yet'), T('Add your first SSH or RDP host or import ~/.ssh/config.'), T('New host'), () => editHost({}));
|
||
const imp = h(`<button class="btn" style="margin-left:8px">${esc(T('Import ~/.ssh/config'))}</button>`);
|
||
imp.onclick = importSshConfig; e.append(imp);
|
||
c.append(e);
|
||
} else c.append(emptyState('search', T('No results'), T('Adjust the search or filters.')));
|
||
}
|
||
}
|
||
|
||
function hostCard(hst) {
|
||
const tags = (hst.tags || []).length ? `<div class="tags">${hst.tags.map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</div>` : '';
|
||
const card = h(`<div class="card">${avatar(hst)}<div class="meta"><div class="title">${esc(hst.label || hst.address)}</div><div class="sub">${esc(hostSub(hst))}</div>${tags}</div>
|
||
<div class="actions">${hst.protocol !== 'rdp' ? `<button data-a="sftp" title="SFTP">${ICONS.folder}</button>` : ''}<button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button></div></div>`);
|
||
card.addEventListener('click', (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'edit') return editHost(hst);
|
||
if (a === 'sftp') return openSftp(hst);
|
||
});
|
||
card.addEventListener('dblclick', (e) => { if (!e.target.closest('[data-a]')) connectHost(hst); });
|
||
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(hst));
|
||
return card;
|
||
}
|
||
|
||
function hostMenu(hst) {
|
||
return [
|
||
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
|
||
...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []),
|
||
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }, { label: T('Network'), icon: 'network', run: () => openNetwork(hst) }] : []),
|
||
'-',
|
||
{ label: T('Edit'), icon: 'edit', run: () => editHost(hst) },
|
||
{ label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } },
|
||
{ label: T('Copy address'), icon: 'copy', run: () => call('clipboard:write', `${hst.username ? hst.username + '@' : ''}${hst.address}`) },
|
||
'-',
|
||
{ label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { await call('vault:remove', 'hosts', hst.id); reload(); } } },
|
||
];
|
||
}
|
||
|
||
function connectHost(hst) {
|
||
if (hst.protocol === 'rdp') return openRdp(hst);
|
||
openTerminal(hst);
|
||
}
|
||
|
||
async function openRdp(hst) {
|
||
if (settings().rdpEmbed !== false) {
|
||
const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message }));
|
||
if (s.ok) return new RdpSession(hst);
|
||
toast(T('RDP in a tab is not possible ({reason}) – opening a separate window.', { reason: s.reason }));
|
||
}
|
||
launchRdp(hst);
|
||
}
|
||
|
||
async function launchRdp(hst) {
|
||
try {
|
||
const client = await call('rdp:launch', hst.id || hst);
|
||
toast(T('RDP session to {name} started ({client})', { name: hst.label || hst.address, client }), 'ok');
|
||
} catch {}
|
||
}
|
||
|
||
function parseQuick(q) {
|
||
const m = q.trim().match(/^(?:(rdp|ssh):\/\/)?(?:([^@\s]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/i);
|
||
if (!m) return null;
|
||
return { protocol: (m[1] || 'ssh').toLowerCase(), username: m[2], address: m[3].replace(/^\[|\]$/g, ''), port: m[4] ? Number(m[4]) : undefined, label: q.trim() };
|
||
}
|
||
function quickConnect(q) {
|
||
const hst = parseQuick(q);
|
||
if (!hst) return toast(T('Invalid address. Format: user@host:port'), 'error');
|
||
connectHost(hst);
|
||
}
|
||
|
||
async function importSshConfig() {
|
||
try { const n = await call('vault:importSshConfig'); toast(T('{n} host(s) imported', { n }), 'ok'); reload(); } catch {}
|
||
}
|
||
|
||
// ============================================================ Drawer / Editoren
|
||
function openDrawer(title, fields, onSave, extraFooter) {
|
||
const d = $('#drawer');
|
||
d.innerHTML = '';
|
||
const form = h('<div class="form"></div>');
|
||
const hd = h(`<header><h2>${esc(title)}</h2><button class="btn ghost sm">${ICONS.close}</button></header>`);
|
||
$('button', hd).onclick = closeDrawer;
|
||
const ft = h('<footer><div class="grow"></div></footer>');
|
||
if (extraFooter) ft.prepend(extraFooter);
|
||
const cancel = h(`<button class="btn ghost">${esc(T('Cancel'))}</button>`); cancel.onclick = closeDrawer;
|
||
const save = h(`<button class="btn primary">${esc(T('Save'))}</button>`);
|
||
save.onclick = async () => { try { if ((await onSave()) !== false) closeDrawer(); } catch (e) { if (e.message) toast(e.message, 'error'); } };
|
||
ft.append(cancel, save);
|
||
form.append(...[].concat(fields));
|
||
d.append(hd, form, ft);
|
||
d.classList.add('open');
|
||
d.onkeydown = (e) => { if (e.key === 'Escape') closeDrawer(); if (e.key === 'Enter' && e.ctrlKey) save.click(); };
|
||
setTimeout(() => $('input,select,textarea', form)?.focus(), 50);
|
||
return form;
|
||
}
|
||
function closeDrawer() { $('#drawer').classList.remove('open'); }
|
||
|
||
function field(label, name, value = '', opts = {}) {
|
||
const { type = 'text', placeholder = '', hint = '', cls = '', options } = opts;
|
||
let input;
|
||
if (type === 'select') input = `<select name="${name}">${options.map(([v, l]) => `<option value="${esc(v)}" ${String(v) === String(value ?? '') ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
|
||
else if (type === 'textarea') input = `<textarea name="${name}" placeholder="${esc(placeholder)}" spellcheck="false">${esc(value)}</textarea>`;
|
||
else if (type === 'password') input = `<div class="pw-wrap"><input name="${name}" type="password" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off" /><button type="button" tabindex="-1">${ICONS.eye}</button></div>`;
|
||
else input = `<input name="${name}" type="${type}" value="${esc(value)}" placeholder="${esc(placeholder)}" spellcheck="false" />`;
|
||
const el = h(`<div class="field ${cls}"><label>${esc(label)}</label>${input}${hint ? `<div class="hint">${esc(hint)}</div>` : ''}</div>`);
|
||
const eye = $('.pw-wrap button', el);
|
||
if (eye) eye.onclick = () => { const i = $('input', el); i.type = i.type === 'password' ? 'text' : 'password'; };
|
||
return el;
|
||
}
|
||
const row = (...els) => { const r = h('<div class="row"></div>'); r.append(...els); return r; };
|
||
const check = (label, name, checked) => h(`<label class="check"><input type="checkbox" name="${name}" ${checked ? 'checked' : ''}/>${esc(label)}</label>`);
|
||
const heading = (t) => h(`<h4>${esc(t)}</h4>`);
|
||
function formValues(root) {
|
||
const o = {};
|
||
$$('[name]', root).forEach((i) => { o[i.name] = i.type === 'checkbox' ? i.checked : i.value; });
|
||
return o;
|
||
}
|
||
function colorPicker(current) {
|
||
const el = h(`<div class="field"><label>${esc(T('Color'))}</label><div class="colors"><input type="hidden" name="color" value="${esc(current || '')}"/></div></div>`);
|
||
const wrap = $('.colors', el);
|
||
['', ...COLORS].forEach((c) => {
|
||
const b = h(`<button type="button" title="${c || T('Automatic')}" style="background:${c || 'conic-gradient(#6e7bff,#3ecf8e,#ff5f6d,#ffb454,#6e7bff)'}" class="${(current || '') === c ? 'active' : ''}"></button>`);
|
||
b.onclick = () => { $('input', wrap).value = c; $$('button', wrap).forEach((x) => x.classList.toggle('active', x === b)); };
|
||
wrap.append(b);
|
||
});
|
||
return el;
|
||
}
|
||
|
||
function groupOptions() {
|
||
const out = [['', T('— No group —')]];
|
||
const walk = (parent, depth) => S.vault.groups.filter((g) => (g.parentId || null) === parent).forEach((g) => { out.push([g.id, `${' '.repeat(depth)}${g.label}`]); walk(g.id, depth + 1); });
|
||
walk(null, 0);
|
||
return out;
|
||
}
|
||
|
||
function editHost(hst = {}) {
|
||
let proto = hst.protocol || 'ssh';
|
||
const isNew = !hst.id;
|
||
const seg = h(`<div class="seg"><button data-p="ssh">SSH</button><button data-p="rdp">RDP</button></div>`);
|
||
const sshBox = h('<div></div>');
|
||
const rdpBox = h('<div></div>');
|
||
const portField = field('Port', 'port', hst.port || '', { type: 'number', placeholder: proto === 'rdp' ? '3389' : '22', cls: 'small' });
|
||
const setProto = (p) => {
|
||
proto = p;
|
||
$$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.p === p));
|
||
sshBox.style.display = p === 'ssh' ? '' : 'none';
|
||
rdpBox.style.display = p === 'rdp' ? '' : 'none';
|
||
$('input', portField).placeholder = p === 'rdp' ? '3389' : '22';
|
||
};
|
||
$$('button', seg).forEach((b) => (b.onclick = () => setProto(b.dataset.p)));
|
||
|
||
const keys = [['', T('— No key —')], ...S.vault.keys.map((k) => [k.id, k.label])];
|
||
const jumps = [['', T('— Direct —')], ...S.vault.hosts.filter((x) => x.id !== hst.id && x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address])];
|
||
sshBox.append(
|
||
field(T('SSH key'), 'keyId', hst.keyId, { type: 'select', options: keys }),
|
||
check(T('Use SSH agent'), 'useAgent', hst.useAgent !== false),
|
||
heading(T('Advanced')),
|
||
field(T('Jump host (ProxyJump)'), 'jumpHostId', hst.jumpHostId, { type: 'select', options: jumps }),
|
||
field(T('Startup command'), 'startupCommand', hst.startupCommand, { placeholder: T('e.g. tmux attach || tmux') }),
|
||
field(T('Environment variables'), 'env', hst.env, { type: 'textarea', placeholder: 'LANG=de_DE.UTF-8', hint: T('One per line, KEY=VALUE (server must allow AcceptEnv).') }),
|
||
);
|
||
rdpBox.append(
|
||
field(T('Domain'), 'domain', hst.domain, { placeholder: 'optional' }),
|
||
row(field(T('Width'), 'rdpWidth', hst.rdpWidth, { type: 'number', placeholder: 'auto' }), field(T('Height'), 'rdpHeight', hst.rdpHeight, { type: 'number', placeholder: 'auto' })),
|
||
field(T('On resize'), 'rdpResize', hst.rdpResize || 'dynamic', { type: 'select', options: [['dynamic', T('Adjust resolution dynamically')], ['scale', T('Scale image (fixed resolution)')]] }),
|
||
check(T('Fullscreen'), 'rdpFullscreen', hst.rdpFullscreen),
|
||
check(T('Share clipboard'), 'rdpClipboard', hst.rdpClipboard !== false),
|
||
check(T('Play audio'), 'rdpAudio', hst.rdpAudio !== false),
|
||
check(T('Share local drives/home'), 'rdpDrives', hst.rdpDrives),
|
||
);
|
||
|
||
const form = openDrawer(isNew ? T('New host') : T('Edit host'), [
|
||
seg,
|
||
row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField),
|
||
field(T('Label'), 'label', hst.label, { placeholder: T('My server') }),
|
||
field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }),
|
||
...(S.vault.vpns.length ? [field(T('VPN'), 'vpnId', hst.vpnId, { type: 'select', options: [['', T('— No VPN —')], ...S.vault.vpns.map((v) => [v.id, v.label])], hint: T('Connected automatically before connecting to this host.') })] : []),
|
||
field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }),
|
||
heading(T('Authentication')),
|
||
field(T('Username'), 'username', hst.username, { placeholder: 'root' }),
|
||
field(T('Password'), 'password', hst.password, { type: 'password', placeholder: T('stored encrypted') }),
|
||
sshBox, rdpBox,
|
||
heading(T('Appearance')),
|
||
colorPicker(hst.color),
|
||
], async () => {
|
||
const v = formValues(form);
|
||
if (!v.address.trim()) throw new Error(T('Please enter an address.'));
|
||
const item = {
|
||
...hst, ...v, protocol: proto,
|
||
address: v.address.trim(),
|
||
port: v.port ? Number(v.port) : undefined,
|
||
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||
groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null, vpnId: v.vpnId || null,
|
||
};
|
||
await call('vault:upsert', 'hosts', item);
|
||
reload();
|
||
}, isNew ? null : (() => { const b = h(`<button class="btn danger">${ICONS.trash}</button>`); b.title = T('Delete'); b.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { await call('vault:remove', 'hosts', hst.id); closeDrawer(); reload(); } }; return b; })());
|
||
setProto(proto);
|
||
return form;
|
||
}
|
||
|
||
function editGroup(g = {}) {
|
||
const parents = groupOptions().filter(([id]) => id !== g.id);
|
||
const form = openDrawer(g.id ? T('Edit group') : T('New group'), [
|
||
field('Name', 'label', g.label, { placeholder: T('Production') }),
|
||
field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: parents }),
|
||
colorPicker(g.color),
|
||
], async () => {
|
||
const v = formValues(form);
|
||
if (!v.label.trim()) throw new Error(T('Name is missing.'));
|
||
await call('vault:upsert', 'groups', { ...g, ...v, parentId: v.parentId || null });
|
||
reload();
|
||
});
|
||
}
|
||
|
||
// ============================================================ Keychain
|
||
function viewKeys(page) {
|
||
page.append(toolbar(T('Search keys …'), [
|
||
{ label: T('Import'), icon: 'download', run: () => editKey({}, true) },
|
||
{ label: T('Generate'), icon: 'plus', cls: 'primary', run: generateKey },
|
||
]));
|
||
const c = h('<div class="content"></div>'); page.append(c);
|
||
const list = S.vault.keys.filter((k) => matches(k.label, k.publicKey));
|
||
if (!list.length) return c.append(emptyState('key', T('No keys'), T('Generate a new Ed25519 key or import an existing one.'), T('Generate key'), generateKey));
|
||
c.append(h(`<div class="section-title">${T('Keys')} <span class="count">${list.length}</span></div>`));
|
||
const grid = h('<div class="grid"></div>');
|
||
for (const k of list) {
|
||
const type = (k.publicKey || '').split(' ')[0] || 'key';
|
||
const used = S.vault.hosts.filter((x) => x.keyId === k.id).length;
|
||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="${T('Copy public key')}">${ICONS.copy}</button><button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button></div></div>`);
|
||
card.onclick = (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'copy') { call('clipboard:write', k.publicKey || ''); toast(T('Public key copied'), 'ok'); }
|
||
else editKey(k);
|
||
};
|
||
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||
{ label: T('Copy public key'), icon: 'copy', run: () => { call('clipboard:write', k.publicKey || ''); toast(T('Public key copied'), 'ok'); } },
|
||
{ label: T('Edit'), icon: 'edit', run: () => editKey(k) },
|
||
'-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete key?'), T('“{name}” will be removed from the vault.', { name: k.label }))) { await call('vault:remove', 'keys', k.id); reload(); } } },
|
||
]);
|
||
grid.append(card);
|
||
}
|
||
c.append(grid);
|
||
}
|
||
|
||
async function generateKey() {
|
||
const r = await modal({
|
||
title: T('Generate SSH key'),
|
||
body: `<div class="field"><label>Name</label><input name="label" value="mrterm-${new Date().toISOString().slice(0, 10)}"/></div>
|
||
<div class="field"><label>${T('Type')}</label><select name="type"><option value="ed25519">Ed25519 (${T('recommended')})</option><option value="ecdsa">ECDSA</option><option value="rsa">RSA 4096</option></select></div>
|
||
<div class="field"><label>Passphrase (optional)</label><input name="passphrase" type="password"/></div>`,
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Generate'), value: 'form', cls: 'primary' }],
|
||
});
|
||
if (!r) return;
|
||
const k = await call('key:generate', { type: r.type, passphrase: r.passphrase, comment: r.label });
|
||
await call('vault:upsert', 'keys', { label: r.label, privateKey: k.privateKey, publicKey: k.publicKey, passphrase: r.passphrase || undefined });
|
||
await call('clipboard:write', k.publicKey);
|
||
toast(T('Key generated – public key copied to clipboard'), 'ok');
|
||
reload();
|
||
}
|
||
|
||
function editKey(k = {}, pick = false) {
|
||
const form = openDrawer(k.id ? T('Edit key') : T('Import key'), [
|
||
field('Name', 'label', k.label, { placeholder: 'id_ed25519' }),
|
||
field('Private Key', 'privateKey', k.privateKey, { type: 'textarea', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----' }),
|
||
field('Passphrase', 'passphrase', k.passphrase, { type: 'password' }),
|
||
field('Public Key', 'publicKey', k.publicKey, { type: 'textarea', hint: T('Derived from the private key automatically if left empty.') }),
|
||
], async () => {
|
||
const v = formValues(form);
|
||
if (!v.privateKey.trim()) throw new Error(T('Private key is missing.'));
|
||
const parsed = await call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase });
|
||
await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey });
|
||
reload();
|
||
}, (() => { const b = h(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); b.onclick = loadFile; return b; })());
|
||
async function loadFile() {
|
||
const f = await call('key:pickFile');
|
||
if (!f) return;
|
||
$('[name=privateKey]', form).value = f.content;
|
||
if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name;
|
||
if (f.publicKey) $('[name=publicKey]', form).value = f.publicKey;
|
||
}
|
||
if (pick) loadFile();
|
||
}
|
||
|
||
// ============================================================ Snippets
|
||
function viewSnippets(page) {
|
||
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
|
||
const c = h('<div class="content"></div>'); page.append(c);
|
||
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
|
||
if (!list.length) return c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet()));
|
||
const l = h('<div class="list"></div>');
|
||
for (const s of list) {
|
||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
|
||
card.onclick = async (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'run') runSnippet(s);
|
||
else if (a === 'copy') { call('clipboard:write', s.command); toast(T('Copied'), 'ok'); }
|
||
else if (a === 'del') { if (await confirmBox(T('Delete snippet?'), s.label)) { await call('vault:remove', 'snippets', s.id); reload(); } }
|
||
else editSnippet(s);
|
||
};
|
||
l.append(card);
|
||
}
|
||
c.append(l);
|
||
}
|
||
function editSnippet(s = {}) {
|
||
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
|
||
field('Name', 'label', s.label, { placeholder: T('System update') }),
|
||
field(T('Command(s)'), 'command', s.command, { type: 'textarea', placeholder: 'sudo pacman -Syu' }),
|
||
check(T('Send Enter automatically'), 'autoRun', s.autoRun !== false),
|
||
], async () => {
|
||
const v = formValues(form);
|
||
if (!v.command.trim()) throw new Error(T('Command is missing.'));
|
||
await call('vault:upsert', 'snippets', { ...s, ...v, label: v.label || v.command.split('\n')[0].slice(0, 40) });
|
||
reload();
|
||
});
|
||
}
|
||
function runSnippet(s, session) {
|
||
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
|
||
if (!t) return toast(T('No open terminal'), 'error');
|
||
t.sendText(s.command.replace(/\r?\n/g, '\r') + (s.autoRun !== false ? '\r' : ''));
|
||
activateTab(t.id);
|
||
t.term.focus();
|
||
}
|
||
|
||
// ============================================================ Port Forwarding
|
||
function viewForwards(page) {
|
||
page.append(toolbar(T('Search rules …'), [{ label: T('New rule'), icon: 'plus', cls: 'primary', run: () => editForward() }]));
|
||
const c = h('<div class="content"></div>'); page.append(c);
|
||
const list = S.vault.forwards.filter((f) => matches(f.label, f.destHost));
|
||
if (!list.length) return c.append(emptyState('fwd', T('No port forwards'), T('Local (-L), remote (-R) or dynamic as SOCKS5 proxy (-D).'), T('New rule'), () => editForward()));
|
||
const l = h('<div class="list"></div>');
|
||
for (const f of list) {
|
||
const hst = S.vault.hosts.find((x) => x.id === f.hostId);
|
||
const on = S.activeForwards.has(f.id);
|
||
const desc = f.type === 'dynamic' ? `SOCKS5 ${f.bindAddress || '127.0.0.1'}:${f.bindPort}`
|
||
: f.type === 'remote' ? `Remote ${f.bindPort} → ${f.destHost || '127.0.0.1'}:${f.destPort}`
|
||
: `${T('Local')} ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`;
|
||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? T('active') : T('inactive')}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div>
|
||
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + T('Stop') : ICONS.play + T('Start')}</button><div class="actions"><button data-a="del">${ICONS.trash}</button></div></div>`);
|
||
card.onclick = async (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'toggle') {
|
||
const b = e.target.closest('button'); b.disabled = true;
|
||
try {
|
||
if (on) await call('fw:stop', f.id);
|
||
else { await call('fw:start', f.id); toast(T('Forward active'), 'ok'); }
|
||
} catch {}
|
||
reload();
|
||
} else if (a === 'del') { if (await confirmBox(T('Delete rule?'), f.label || desc)) { await call('fw:stop', f.id); await call('vault:remove', 'forwards', f.id); reload(); } }
|
||
else editForward(f);
|
||
};
|
||
l.append(card);
|
||
}
|
||
c.append(l);
|
||
}
|
||
function editForward(f = {}) {
|
||
const hosts = S.vault.hosts.filter((x) => x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address]);
|
||
if (!hosts.length) return toast(T('Add an SSH host first.'), 'error');
|
||
const form = openDrawer(f.id ? T('Edit rule') : T('New forward'), [
|
||
field(T('Label'), 'label', f.label, { placeholder: 'Postgres Prod' }),
|
||
field(T('Type'), 'type', f.type || 'local', { type: 'select', options: [['local', T('Local (-L)')], ['remote', 'Remote (-R)'], ['dynamic', T('Dynamic / SOCKS5 (-D)')]] }),
|
||
field(T('Via host'), 'hostId', f.hostId, { type: 'select', options: hosts }),
|
||
row(field(T('Bind address'), 'bindAddress', f.bindAddress, { placeholder: '127.0.0.1' }), field(T('Bind port'), 'bindPort', f.bindPort, { type: 'number', placeholder: '8080', cls: 'small' })),
|
||
row(field(T('Target host'), 'destHost', f.destHost, { placeholder: 'localhost' }), field(T('Target port'), 'destPort', f.destPort, { type: 'number', placeholder: '5432', cls: 'small' })),
|
||
], async () => {
|
||
const v = formValues(form);
|
||
if (!v.bindPort) throw new Error(T('Bind port is missing.'));
|
||
if (v.type !== 'dynamic' && !v.destPort) throw new Error(T('Target port is missing.'));
|
||
await call('vault:upsert', 'forwards', { ...f, ...v });
|
||
reload();
|
||
});
|
||
}
|
||
|
||
// ============================================================ VPN (WireGuard / OpenVPN)
|
||
S.vpnStatus = {};
|
||
const VPN_TYPES = { wireguard: 'WireGuard', openvpn: 'OpenVPN' };
|
||
|
||
function viewVpns(page) {
|
||
page.append(toolbar(T('Search VPNs …'), [
|
||
{ label: T('Import'), icon: 'download', run: () => editVpn({}, true) },
|
||
{ label: T('New VPN'), icon: 'plus', cls: 'primary', run: () => editVpn() },
|
||
]));
|
||
const c = h('<div class="content"></div>'); page.append(c);
|
||
const list = S.vault.vpns.filter((v) => matches(v.label, VPN_TYPES[v.type]));
|
||
if (!list.length) return c.append(emptyState('shield', T('No VPNs'), T('Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.'), T('New VPN'), () => editVpn()));
|
||
const l = h('<div class="list"></div>');
|
||
for (const v of list) {
|
||
const on = !!S.vpnStatus[v.id];
|
||
const n = S.vault.hosts.filter((x) => x.vpnId === v.id).length;
|
||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.shield}</div><div class="meta"><div class="title">${esc(v.label)} <span class="status-pill ${on ? 'on' : ''}">${on ? T('connected') : T('disconnected')}</span></div><div class="sub">${VPN_TYPES[v.type] || v.type} · ${n} Host${n === 1 ? '' : 's'}</div></div>
|
||
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + T('Disconnect') : ICONS.play + T('Connect')}</button><div class="actions"><button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
|
||
card.onclick = async (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'toggle') {
|
||
const b = e.target.closest('button'); b.disabled = true; b.innerHTML = `<span class="spinner sm"></span>${on ? T('Disconnect') : T('Connecting …')}`;
|
||
try { await call(on ? 'vpn:down' : 'vpn:up', v.id); if (!on) toast(T('VPN “{name}” connected', { name: v.label }), 'ok'); } catch {}
|
||
S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||
render();
|
||
} else if (a === 'del') {
|
||
if (await confirmBox(T('Delete VPN?'), T('“{name}” will be removed. Assigned hosts will connect without VPN.', { name: v.label }))) { await call('vault:remove', 'vpns', v.id); reload(); }
|
||
} else editVpn(v);
|
||
};
|
||
l.append(card);
|
||
}
|
||
c.append(l);
|
||
}
|
||
|
||
function editVpn(v = {}, pick = false) {
|
||
let type = v.type || 'wireguard';
|
||
const seg = h('<div class="seg"><button data-t="wireguard">WireGuard</button><button data-t="openvpn">OpenVPN</button></div>');
|
||
const ovpnBox = h('<div></div>');
|
||
ovpnBox.append(
|
||
heading(T('Authentication')),
|
||
field(T('Username'), 'username', v.username, { placeholder: T('optional') }),
|
||
field(T('Password'), 'password', v.password, { type: 'password', placeholder: T('stored encrypted') }),
|
||
);
|
||
const cfg = field(T('Configuration'), 'config', v.config, { type: 'textarea', placeholder: '[Interface]\nPrivateKey = …', hint: '' });
|
||
$('textarea', cfg).style.minHeight = '180px';
|
||
const setType = (t) => {
|
||
type = t;
|
||
$$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.t === t));
|
||
ovpnBox.style.display = t === 'openvpn' ? '' : 'none';
|
||
$('textarea', cfg).placeholder = t === 'openvpn' ? 'client\ndev tun\nremote vpn.example.com 1194\n<ca>…</ca>' : '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\n\n[Peer]\n…';
|
||
const hint = $('.hint', cfg) || cfg.appendChild(h('<div class="hint"></div>'));
|
||
hint.textContent = t === 'openvpn' ? T('Certificates and keys must be embedded in the .ovpn file (<ca>, <cert>, <key> …).') : T('Contents of a WireGuard .conf file.');
|
||
};
|
||
$$('button', seg).forEach((b) => (b.onclick = () => setType(b.dataset.t)));
|
||
|
||
// Hosts zuordnen
|
||
const hostBox = h(`<div class="vpn-hosts"></div>`);
|
||
const sorted = [...S.vault.hosts].sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
|
||
if (!sorted.length) hostBox.append(h(`<div class="hint">${T('No hosts yet')}</div>`));
|
||
sorted.forEach((x) => hostBox.append(h(`<label class="check"><input type="checkbox" data-host="${x.id}" ${x.vpnId && x.vpnId === v.id ? 'checked' : ''}/>${esc(x.label || x.address)}<span style="color:var(--faint);margin-left:6px;font-size:11px">${esc(hostSub(x))}</span></label>`)));
|
||
|
||
const form = openDrawer(v.id ? T('Edit VPN') : T('New VPN'), [
|
||
seg,
|
||
field(T('Name'), 'label', v.label, { placeholder: T('Office VPN') }),
|
||
cfg,
|
||
ovpnBox,
|
||
check(T('Disconnect when MrTerm closes'), 'disconnectOnQuit', v.disconnectOnQuit !== false),
|
||
heading(T('Assigned hosts')),
|
||
hostBox,
|
||
], async () => {
|
||
const f = formValues(form);
|
||
if (!f.config.trim()) throw new Error(T('Configuration is missing.'));
|
||
if (type === 'wireguard' && !/\[Interface\]/i.test(f.config)) throw new Error(T('This does not look like a WireGuard configuration.'));
|
||
const item = { ...v, type, label: f.label.trim() || VPN_TYPES[type], config: f.config, disconnectOnQuit: f.disconnectOnQuit,
|
||
username: type === 'openvpn' ? f.username : '', password: type === 'openvpn' ? f.password : '' };
|
||
const saved = await call('vault:upsert', 'vpns', item);
|
||
for (const cb of $$('[data-host]', hostBox)) {
|
||
const hst = S.vault.hosts.find((x) => x.id === cb.dataset.host);
|
||
const want = cb.checked ? saved.id : (hst.vpnId === saved.id ? null : hst.vpnId || null);
|
||
if ((hst.vpnId || null) !== want) await call('vault:upsert', 'hosts', { ...hst, vpnId: want });
|
||
}
|
||
reload();
|
||
}, (() => { const b = h(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); b.onclick = loadFile; return b; })());
|
||
async function loadFile() {
|
||
const f = await call('import:pickFile', T('VPN configuration'), ['conf', 'ovpn']);
|
||
if (!f) return;
|
||
$('[name=config]', form).value = f.content;
|
||
if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name.replace(/\.(conf|ovpn)$/i, '');
|
||
setType(/\.ovpn$/i.test(f.name) || /^\s*(client|remote)\b/m.test(f.content) ? 'openvpn' : 'wireguard');
|
||
}
|
||
setType(type);
|
||
if (pick) loadFile();
|
||
}
|
||
|
||
// ============================================================ Known Hosts & Verlauf
|
||
function viewKnown(page) {
|
||
page.append(toolbar(T('Search known hosts …')));
|
||
const c = h('<div class="content"></div>'); page.append(c);
|
||
const entries = Object.entries(S.vault.knownHosts).filter(([id, v]) => matches(id, v.fingerprint));
|
||
if (!entries.length) return c.append(emptyState('shield', T('No known hosts'), T('Host keys are stored here on first connection.')));
|
||
const l = h('<div class="list"></div>');
|
||
for (const [id, v] of entries) {
|
||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="${T('Remove')}">${ICONS.trash}</button></div></div>`);
|
||
$('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); };
|
||
l.append(card);
|
||
}
|
||
c.append(l);
|
||
}
|
||
function viewHistory(page) {
|
||
page.append(toolbar(T('Search history …')));
|
||
const c = h('<div class="content"></div>'); page.append(c);
|
||
const items = S.vault.history.map((x) => ({ ...x, host: S.vault.hosts.find((y) => y.id === x.hostId) })).filter((x) => x.host && matches(x.host.label, x.host.address));
|
||
if (!items.length) return c.append(emptyState('clock', T('No connections yet'), T('Recently used hosts appear here.')));
|
||
const l = h('<div class="list"></div>');
|
||
for (const it of items) {
|
||
const card = h(`<div class="card">${avatar(it.host)}<div class="meta"><div class="title">${esc(it.host.label || it.host.address)}</div><div class="sub">${esc(hostSub(it.host))} · ${relTime(it.at)}</div></div><button class="btn sm">${T('Connect')}</button></div>`);
|
||
card.onclick = () => connectHost(it.host);
|
||
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(it.host));
|
||
l.append(card);
|
||
}
|
||
c.append(l);
|
||
}
|
||
|
||
// ============================================================ Einstellungen
|
||
async function viewSettings(page) {
|
||
const st = settings();
|
||
page.append(h(`<div class="toolbar"><h2 style="margin:0;font-size:18px">${T('Settings')}</h2></div>`));
|
||
const c = h('<div class="content settings-grid"></div>'); page.append(c);
|
||
const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); };
|
||
|
||
// ---- Sprache
|
||
const langCard = h(`<div class="settings-card"><h3>${T('Language')}</h3></div>`);
|
||
const langField = field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System default')], ...I18N.LANGUAGES] });
|
||
langField.onchange = async () => { await save({ language: $('select', langField).value }); I18N.setLanguage(settings().language, navigator.language); reload(); };
|
||
langCard.append(langField);
|
||
|
||
// ---- App-Design
|
||
const designCard = h(`<div class="settings-card"><h3>Design</h3><div class="theme-list design-list"></div><div class="field" style="margin:16px 0 0"><label>${T('Accent color')}</label><div class="colors accent-colors"></div></div></div>`);
|
||
for (const [id, t] of Object.entries(APP_THEMES)) {
|
||
const p = h(`<div class="theme-prev design-prev ${st.appTheme === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
|
||
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div><div class="nm">${esc(T(t.name))}</div></div>`);
|
||
p.onclick = async () => { await save({ appTheme: id }); applyAppTheme(); render(); };
|
||
$('.design-list', designCard).append(p);
|
||
}
|
||
['', ...COLORS.slice(0, 7), '#21c7a8', '#cba6f7'].forEach((col) => {
|
||
const b = h(`<button type="button" title="${col || T('Theme default')}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
|
||
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
|
||
$('.accent-colors', designCard).append(b);
|
||
});
|
||
|
||
const themeCard = h(`<div class="settings-card"><h3>${T('Terminal color scheme')}</h3><div class="theme-list"></div></div>`);
|
||
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
|
||
const autoP = h(`<div class="theme-prev ${st.terminalTheme === 'auto' ? 'active' : ''}" style="background:${autoT.background};color:${autoT.foreground}"><div class="nm">${T('Match theme')}</div><span style="color:${autoT.green}">user@host</span>:<span style="color:${autoT.blue}">~</span>$ ls<br><span style="color:${autoT.blue}">${T('follows the theme')}</span></div>`);
|
||
autoP.onclick = async () => { await save({ terminalTheme: 'auto' }); render(); };
|
||
$('.theme-list', themeCard).append(autoP);
|
||
for (const [id, t] of Object.entries(TERM_THEMES)) {
|
||
const p = h(`<div class="theme-prev ${st.terminalTheme === id ? 'active' : ''}" style="background:${t.background};color:${t.foreground}"><div class="nm">${esc(t.name)}</div><span style="color:${t.green}">user@host</span>:<span style="color:${t.blue}">~</span>$ ls<br><span style="color:${t.blue}">docs</span> <span style="color:${t.green}">run.sh</span> <span style="color:${t.red}">err</span></div>`);
|
||
p.onclick = async () => { await save({ terminalTheme: id }); render(); };
|
||
$('.theme-list', themeCard).append(p);
|
||
}
|
||
|
||
const termCard = h('<div class="settings-card"><h3>Terminal</h3></div>');
|
||
termCard.append(
|
||
row(field(T('Font'), 'fontFamily', st.fontFamily), field(T('Size'), 'fontSize', st.fontSize, { type: 'number', cls: 'small' })),
|
||
row(field('Cursor', 'cursorStyle', st.cursorStyle, { type: 'select', options: [['block', 'Block'], ['bar', T('Bar')], ['underline', T('Underline')]] }), field(T('Scrollback lines'), 'scrollback', st.scrollback, { type: 'number' })),
|
||
check(T('Blinking cursor'), 'cursorBlink', st.cursorBlink),
|
||
check(T('Copy on select'), 'copyOnSelect', st.copyOnSelect),
|
||
field(T('SSH keep-alive (seconds, 0 = off)'), 'keepAlive', st.keepAlive, { type: 'number' }),
|
||
);
|
||
termCard.addEventListener('change', () => {
|
||
const v = formValues(termCard);
|
||
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
|
||
});
|
||
|
||
const rdpCard = h(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`);
|
||
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
|
||
emb.onchange = async () => {
|
||
await save({ rdpEmbed: $('input', emb).checked });
|
||
if (api.platform === 'linux') toast(T('Takes effect after restarting MrTerm (X11/XWayland mode).'));
|
||
};
|
||
rdpCard.append(emb);
|
||
if (api.platform === 'linux') rdpCard.append(h(`<div class="hint" style="color:var(--faint);margin:-4px 0 12px 26px;font-size:11px">${T('Uses xfreerdp3 with /parent-window. For this, MrTerm runs via XWayland under Wayland.')}</div>`));
|
||
if (api.platform !== 'win32') {
|
||
const f = field(T('FreeRDP client (separate window)'), 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', T('Automatic')], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] });
|
||
f.onchange = () => save({ rdpClientLinux: $('select', f).value }).then(render);
|
||
rdpCard.append(f);
|
||
}
|
||
api.call('rdp:detect').then((d) => {
|
||
$('.rdp-info', rdpCard).textContent = d.available ? T('Client in use: {client}', { client: d.client }) : T('No RDP client found. Install: {hint}', { hint: d.hint });
|
||
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
|
||
}).catch(() => {});
|
||
|
||
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
|
||
const btns = [
|
||
[T('Import ~/.ssh/config'), importSshConfig],
|
||
[T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }],
|
||
[T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }],
|
||
];
|
||
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
|
||
|
||
// ---- Import aus anderen Programmen
|
||
const importCard = h(`<div class="settings-card"><h3>${T('Import')}</h3><p style="color:var(--muted);margin-top:0">${T('Import folders and hosts (RDP & SSH) from <b>Devolutions Remote Desktop Manager</b>. In RDM: <i>File → Export → Export entries</i> as <code>.rdm</code> (XML), JSON or CSV. RDM only exports passwords unencrypted if you enable that during export.')}</p></div>`);
|
||
const rdmBtn = h(`<button class="btn primary">${T('Import from Remote Desktop Manager…')}</button>`);
|
||
rdmBtn.onclick = importRdm;
|
||
importCard.append(rdmBtn);
|
||
|
||
// ---- App-Sperre
|
||
const secCard = h(`<div class="settings-card"><h3>${T('App lock')}</h3><p style="color:var(--muted);margin-top:0">${T('Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.')}</p><div class="lock-rows"></div></div>`);
|
||
renderLockSettings($('.lock-rows', secCard));
|
||
|
||
// ---- Updates
|
||
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">${T('Installed version: {v}', { v: '…' })}</p></div>`);
|
||
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); });
|
||
const updFields = h('<div></div>');
|
||
updFields.append(
|
||
field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: T('Gitea/Forgejo repository whose releases contain the installation packages.') }),
|
||
field(T('Access token (private repos only)'), 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }),
|
||
check(T('Check for updates on startup'), 'updateAutoCheck', st.updateAutoCheck),
|
||
check(T('Include pre-releases'), 'updatePrerelease', st.updatePrerelease),
|
||
);
|
||
updFields.addEventListener('change', () => save(formValues(updFields)));
|
||
const chk = h(`<button class="btn">${ICONS.refresh}${T('Check for updates now')}</button>`);
|
||
chk.onclick = async () => {
|
||
chk.disabled = true;
|
||
try { await save(formValues(updFields)); const r = await call('update:check'); if (r.available) showUpdate(r);
|
||
else if (r.repoVersion) toast(T('The repository has version {repo}, but there is no release with installation packages for it yet. Installed: {current}.', { repo: r.repoVersion, current: r.current }), 'error');
|
||
else if (r.noRelease) toast(T('The repository has no releases yet.'), 'error');
|
||
else toast(T('MrTerm is up to date ({v}).', { v: r.current }), 'ok'); } catch {}
|
||
chk.disabled = false;
|
||
};
|
||
updCard.append(updFields, chk);
|
||
|
||
const C = `<kbd>${T('Ctrl')}</kbd>`;
|
||
const keysCard = h(`<div class="settings-card"><h3>${T('Keyboard shortcuts')}</h3><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 18px;color:var(--muted)">
|
||
<span>${C}+<kbd>Shift</kbd>+<kbd>K</kbd> / <kbd>T</kbd></span><span>${T('Quick Connect / command palette (outside the terminal also {k})', { k: `${C}+<kbd>K</kbd>` })}</span>
|
||
<span>${C}+<kbd>Shift</kbd>+<kbd>W</kbd></span><span>${T('Close tab')}</span>
|
||
<span>${C}+<kbd>Tab</kbd></span><span>${T('Next tab')}</span>
|
||
<span>${C}+<kbd>1..9</kbd></span><span>${T('Switch to tab')}</span>
|
||
<span>${C}+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>${T('Copy / paste in terminal')}</span>
|
||
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
|
||
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
||
c.append(langCard, secCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
||
}
|
||
|
||
// ============================================================ App-Sperre
|
||
function showLock() {
|
||
if ($('.lock-screen')) return;
|
||
const m = S.lock.meta || {};
|
||
I18N.setLanguage(m.language, navigator.language);
|
||
applyStatic();
|
||
applyAppTheme(m);
|
||
closeDrawer();
|
||
$$('.ctx').forEach((x) => x.remove());
|
||
$('.palette')?.closest('.modal-bg')?.remove();
|
||
const el = h(`<div class="lock-screen"><div class="lock-box">
|
||
<img class="lock-logo" src="logo.png" alt=""/><h2>${T('MrTerm is locked')}</h2>
|
||
${S.lock.hasPassword ? `<form class="lock-pw"><input type="password" name="pw" placeholder="${esc(T('Password'))}" autocomplete="off"/><button class="btn primary">${ICONS.unlock}${T('Unlock')}</button></form>` : ''}
|
||
${S.lock.fido.length ? `<button class="btn lock-fido">${ICONS.key}${T('Unlock with security key')}</button>` : ''}
|
||
<div class="lock-err"></div></div></div>`);
|
||
const err = $('.lock-err', el);
|
||
const done = async () => { el.remove(); await reload(); };
|
||
const form = $('.lock-pw', el);
|
||
if (form) form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const inp = $('input', form);
|
||
err.textContent = '';
|
||
try { await api.call('lock:unlockPassword', inp.value); done(); } catch (x) { err.textContent = x.message; inp.select(); }
|
||
};
|
||
const fb = $('.lock-fido', el);
|
||
if (fb) fb.onclick = async () => {
|
||
err.textContent = ''; fb.disabled = true;
|
||
try { await api.call('lock:unlockFido'); done(); } catch (x) { err.textContent = x.message; }
|
||
fb.disabled = false;
|
||
};
|
||
document.body.append(el);
|
||
($('input', el) || fb)?.focus();
|
||
}
|
||
|
||
async function lockNow() {
|
||
if (!S.lock?.enabled) return;
|
||
S.lock = await call('lock:lock');
|
||
if (S.lock.locked) showLock();
|
||
}
|
||
$('#lockBtn').onclick = lockNow;
|
||
|
||
// Automatische Sperre nach Inaktivität (Einstellung autoLock in Minuten, 0 = aus)
|
||
let lastActivity = Date.now();
|
||
['keydown', 'mousedown', 'mousemove', 'wheel'].forEach((ev) => document.addEventListener(ev, () => { lastActivity = Date.now(); }, { capture: true, passive: true }));
|
||
setInterval(() => {
|
||
const min = Number(S.vault?.settings.autoLock) || 0;
|
||
if (!min || !S.lock?.enabled || S.lock.locked || $('.lock-screen')) return;
|
||
// In einem eingebetteten RDP-Fenster sieht MrTerm keine Eingaben – dort nicht automatisch sperren
|
||
if (S.tabs.some((t) => t.id === S.active && t.kind === 'rdp')) { lastActivity = Date.now(); return; }
|
||
if (Date.now() - lastActivity > min * 60000) lockNow();
|
||
}, 15000);
|
||
|
||
function renderLockSettings(box) {
|
||
const L = S.lock;
|
||
box.innerHTML = '';
|
||
const refresh = (st) => { S.lock = st; $('#lockBtn').style.display = st.enabled ? '' : 'none'; renderLockSettings(box); };
|
||
const disableWarn = async () => (L.hasPassword ? 1 : 0) + L.fido.length > 1 || confirmBox(T('Disable app lock?'), T('This is the last unlock method. MrTerm will no longer be locked.'), T('Disable'));
|
||
|
||
// Passwort
|
||
const pwRow = h(`<div class="lock-row"><div class="grow"><b>${T('Password')}</b><div class="sub">${L.hasPassword ? T('Set') : T('Not set')}</div></div></div>`);
|
||
const pwBtn = h(`<button class="btn">${L.hasPassword ? T('Change password') : T('Set password')}</button>`);
|
||
pwBtn.onclick = async () => {
|
||
const r = await modal({ title: L.hasPassword ? T('Change password') : T('Set password'),
|
||
body: `<div class="field"><label>${T('New password')}</label><input name="a" type="password" autocomplete="off"/></div><div class="field"><label>${T('Repeat password')}</label><input name="b" type="password" autocomplete="off"/></div>`,
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||
if (!r) return;
|
||
if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
|
||
try { refresh(await call('lock:setPassword', r.a)); toast(T('Password saved'), 'ok'); } catch {}
|
||
};
|
||
pwRow.append(pwBtn);
|
||
if (L.hasPassword) {
|
||
const rm = h(`<button class="btn ghost" title="${esc(T('Remove'))}">${ICONS.trash}</button>`);
|
||
rm.onclick = async () => { if (await disableWarn()) refresh(await call('lock:removePassword')); };
|
||
pwRow.append(rm);
|
||
}
|
||
box.append(pwRow);
|
||
|
||
// Sicherheitsschlüssel
|
||
for (const k of L.fido) {
|
||
const row = h(`<div class="lock-row"><div class="grow"><b>${ICONS.key}${esc(k.label)}</b><div class="sub">${T('Security key (FIDO2)')}</div></div></div>`);
|
||
const rm = h(`<button class="btn ghost" title="${esc(T('Remove'))}">${ICONS.trash}</button>`);
|
||
rm.onclick = async () => { if (await disableWarn() && await confirmBox(T('Remove security key?'), k.label, T('Remove'))) refresh(await call('lock:removeFido', k.id)); };
|
||
row.append(rm);
|
||
box.append(row);
|
||
}
|
||
const add = h(`<button class="btn">${ICONS.plus}${T('Add security key')}</button>`);
|
||
add.onclick = async () => {
|
||
const label = await promptBox(T('Add security key'), T('Name'), 'YubiKey');
|
||
if (label == null) return;
|
||
add.disabled = true;
|
||
try { refresh(await call('lock:addFido', label)); toast(T('Security key added'), 'ok'); } catch {}
|
||
add.disabled = false;
|
||
};
|
||
const actions = h('<div class="row" style="flex-wrap:wrap;margin-top:12px"></div>');
|
||
actions.append(add);
|
||
if (L.enabled) {
|
||
const now = h(`<button class="btn primary">${ICONS.lock}${T('Lock now')}</button>`);
|
||
now.onclick = lockNow;
|
||
actions.append(now);
|
||
}
|
||
box.append(actions);
|
||
|
||
if (L.enabled) {
|
||
const al = field(T('Lock automatically after inactivity'), 'autoLock', String(settings().autoLock || 0), { type: 'select',
|
||
options: [['0', T('Never')], ...[1, 5, 10, 15, 30, 60].map((n) => [String(n), T('{n} minutes', { n })])] });
|
||
al.style.marginTop = '14px';
|
||
al.onchange = async () => { const v = Number($('select', al).value); await call('vault:settings', { autoLock: v }); S.vault.settings.autoLock = v; };
|
||
box.append(al);
|
||
box.append(h(`<div class="hint" style="color:var(--faint);font-size:11px;margin-top:10px">${T('If you forget the password and lose all security keys, the vault cannot be recovered.')}</div>`));
|
||
}
|
||
}
|
||
|
||
// ============================================================ Design
|
||
function termTheme() {
|
||
const id = settings().terminalTheme;
|
||
if (id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
|
||
return TERM_THEMES[id] || TERM_THEMES.mrterm;
|
||
}
|
||
function applyAppTheme(st = settings()) {
|
||
const root = document.documentElement;
|
||
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
|
||
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
|
||
S.tabs.forEach((t) => t.applySettings?.());
|
||
}
|
||
|
||
// ============================================================ RDM-Import
|
||
async function importRdm() {
|
||
const f = await call('import:pickFile', 'Remote Desktop Manager Export', ['rdm', 'xml', 'json', 'csv']);
|
||
if (!f) return;
|
||
let entries;
|
||
try { entries = parseRdmExport(f.name, f.content); } catch (e) { return toast(T('Import failed: {msg}', { msg: e.message }), 'error'); }
|
||
const hosts = entries.filter((e) => e.host);
|
||
if (!hosts.length) return toast(T('No RDP or SSH entries found in the file.'), 'error');
|
||
const folders = new Set(entries.map((e) => e.folder.join(' / ')).filter(Boolean));
|
||
const rdpN = hosts.filter((e) => e.host.protocol === 'rdp').length;
|
||
const preview = hosts.slice(0, 8).map((e) => `<div class="card" style="padding:8px 10px;cursor:default">${avatar(e.host)}<div class="meta"><div class="title">${esc(e.host.label)}</div><div class="sub">${esc(e.folder.join(' / ') || T('Root level'))} · ${esc(hostSub(e.host))}</div></div></div>`).join('');
|
||
const ok = await modal({
|
||
title: T('Import from Remote Desktop Manager'),
|
||
text: T('{file}: found {n} hosts ({rdp} RDP, {ssh} SSH) in {folders} folders.', { file: f.name, n: hosts.length, rdp: rdpN, ssh: hosts.length - rdpN, folders: folders.size }),
|
||
body: `<div class="list" style="max-height:300px;overflow:auto">${preview}</div>${hosts.length > 8 ? `<p style="margin:10px 0 0">${T('… and {n} more', { n: hosts.length - 8 })}</p>` : ''}`,
|
||
buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: T('Import'), value: true, cls: 'primary' }],
|
||
});
|
||
if (!ok) return;
|
||
const r = await call('vault:bulkImport', entries);
|
||
toast(T('{n} hosts imported', { n: r.hosts }) + (r.skipped ? T(', {n} already existed', { n: r.skipped }) : ''), 'ok');
|
||
await reload();
|
||
$('[data-view=hosts]').click();
|
||
}
|
||
|
||
// ============================================================ Updates
|
||
let updateInfo = null;
|
||
async function showUpdate(info) {
|
||
updateInfo = info;
|
||
const b = $('#updateBadge');
|
||
b.style.display = '';
|
||
b.querySelector('span').textContent = `Update ${info.version}`;
|
||
const r = await modal({
|
||
title: T('MrTerm {v} is available', { v: info.version }),
|
||
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
|
||
body: `${info.notes ? `<div class="mono" style="white-space:pre-wrap;max-height:240px;overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:6px">${esc(info.notes)}</div>` : ''}
|
||
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
|
||
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
|
||
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
|
||
});
|
||
if (r === 'web') return api.call('shell:open', info.url);
|
||
if (r !== true) return;
|
||
toast(T('Downloading update …'));
|
||
try { await call('update:download'); toast(T('Installing update, MrTerm will restart …'), 'ok'); await call('update:install'); } catch {}
|
||
}
|
||
api.on('update:available', (info) => showUpdate(info));
|
||
api.on('update:progress', (done, total) => {
|
||
const b = $('#updateBadge span');
|
||
if (b) b.textContent = total ? `Update ${Math.round((done / total) * 100)}%` : 'Update …';
|
||
});
|
||
|
||
// ============================================================ Tabs
|
||
function addTab(session) {
|
||
S.tabs.push(session);
|
||
const tab = h(`<div class="tab" data-tab="${session.id}"><span class="dot wait"></span><span>${esc(session.title)}</span><button class="x" title="${T('Close (Ctrl+Shift+W)')}">${ICONS.close}</button></div>`);
|
||
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
|
||
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
|
||
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[session.kind] || openTerminal)(session.host) }] : []),
|
||
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
|
||
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
|
||
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
|
||
{ label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) },
|
||
]);
|
||
session.tabEl = tab;
|
||
$('#tabs').append(tab);
|
||
$('#sessions').append(session.el);
|
||
activateTab(session.id);
|
||
}
|
||
function setTabState(session, state) { const d = $('.dot', session.tabEl); if (d) d.className = 'dot ' + state; }
|
||
|
||
function activateTab(id) {
|
||
S.active = id;
|
||
$$('#tabs .tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === id));
|
||
$('#homeView').classList.toggle('active', id === 'home');
|
||
document.body.classList.toggle('in-session', id !== 'home');
|
||
S.tabs.forEach((t) => {
|
||
t.el.classList.toggle('active', t.id === id);
|
||
if (t.id === id) requestAnimationFrame(() => t.onShow?.());
|
||
else t.onHide?.();
|
||
});
|
||
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||
renderTree();
|
||
}
|
||
function closeTab(id) {
|
||
const i = S.tabs.findIndex((t) => t.id === id);
|
||
if (i < 0) return;
|
||
const t = S.tabs[i];
|
||
t.dispose();
|
||
t.el.remove(); t.tabEl.remove();
|
||
S.tabs.splice(i, 1);
|
||
if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home');
|
||
}
|
||
$('.tab.home').onclick = () => activateTab('home');
|
||
|
||
// ============================================================ Terminal-Session
|
||
class TerminalSession {
|
||
constructor(host) {
|
||
this.kind = 'ssh';
|
||
this.id = uid();
|
||
this.host = host;
|
||
this.title = host.label || host.address;
|
||
this.el = h(`<div class="session">
|
||
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
|
||
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button><button class="btn ghost sm" data-a="network" title="${T('Network')}">${ICONS.network}${T('Network')}</button>`}
|
||
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
|
||
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||
</div>
|
||
<div class="term-wrap"><div class="term"></div>
|
||
<div class="findbar"><input placeholder="${T('Search …')}"/><button class="btn ghost sm" data-f="prev">↑</button><button class="btn ghost sm" data-f="next">↓</button><button class="btn ghost sm" data-f="close">${ICONS.close}</button></div>
|
||
<div class="snip-panel"><div class="head">Snippets<button class="btn ghost sm" data-a="snipnew">${ICONS.plus}</button></div><div class="items"></div></div>
|
||
</div></div>`);
|
||
this.overlay = null;
|
||
this.alive = false;
|
||
this.initTerm();
|
||
this.bindUi();
|
||
this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
|
||
addTab(this);
|
||
this.connect();
|
||
}
|
||
|
||
initTerm() {
|
||
const st = settings();
|
||
this.term = new Terminal({
|
||
fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink,
|
||
scrollback: st.scrollback, theme: termTheme(), allowProposedApi: true, macOptionIsMeta: true,
|
||
});
|
||
this.fit = new FitAddon.FitAddon();
|
||
this.search = new SearchAddon.SearchAddon();
|
||
this.term.loadAddon(this.fit);
|
||
this.term.loadAddon(this.search);
|
||
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_e, url) => api.call('shell:open', url)));
|
||
this.term.open($('.term', this.el));
|
||
$('.term', this.el).style.background = termTheme().background; // Innenabstand in Terminalfarbe
|
||
this.term.onData((d) => { if (this.alive) api.ssh.write(this.id, d); else if (d === '\r' && this.ended) this.reconnect(); });
|
||
this.term.onResize(({ cols, rows }) => { if (this.alive) api.ssh.resize(this.id, cols, rows); });
|
||
this.term.onSelectionChange(() => { if (settings().copyOnSelect && this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); });
|
||
this.term.attachCustomKeyEventHandler((e) => {
|
||
if (e.type !== 'keydown') return true;
|
||
if (e.ctrlKey && e.shiftKey && e.code === 'KeyC') { if (this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); return false; }
|
||
if (e.ctrlKey && e.shiftKey && e.code === 'KeyV') { this.paste(); return false; }
|
||
if (e.ctrlKey && e.shiftKey && e.code === 'KeyF') { this.toggleFind(true); return false; }
|
||
// App-Kürzel im Terminal nur mit Strg+Shift, damit Strg+W/K/T (nano, bash, …) im Terminal ankommen
|
||
if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW', 'KeyL'].includes(e.code)) return false;
|
||
if (e.ctrlKey && e.code === 'Tab') return false;
|
||
if (e.ctrlKey && /^Digit[1-9]$/.test(e.code)) return false;
|
||
if (e.ctrlKey && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) { this.zoom(e.key); return false; }
|
||
return true;
|
||
});
|
||
$('.term', this.el).addEventListener('contextmenu', (e) => {
|
||
e.preventDefault();
|
||
if (this.term.hasSelection()) { api.call('clipboard:write', this.term.getSelection()); this.term.clearSelection(); toast(T('Copied')); }
|
||
else this.paste();
|
||
});
|
||
this.ro = new ResizeObserver(() => { if (this.el.classList.contains('active')) this.doFit(); });
|
||
this.ro.observe($('.term', this.el));
|
||
}
|
||
|
||
bindUi() {
|
||
this.el.addEventListener('click', (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'sftp') openSftp(this.host);
|
||
if (a === 'docker') openDocker(this.host);
|
||
if (a === 'firewall') openFirewall(this.host);
|
||
if (a === 'network') openNetwork(this.host);
|
||
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
|
||
if (a === 'snipnew') editSnippet();
|
||
if (a === 'find') this.toggleFind();
|
||
const f = e.target.closest('[data-f]')?.dataset.f;
|
||
const inp = $('.findbar input', this.el);
|
||
if (f === 'next') this.search.findNext(inp.value);
|
||
if (f === 'prev') this.search.findPrevious(inp.value);
|
||
if (f === 'close') this.toggleFind(false);
|
||
});
|
||
$('.findbar input', this.el).addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') (e.shiftKey ? this.search.findPrevious : this.search.findNext).call(this.search, e.target.value);
|
||
if (e.key === 'Escape') this.toggleFind(false);
|
||
});
|
||
}
|
||
|
||
renderSnips() {
|
||
const box = $('.snip-panel .items', this.el);
|
||
box.innerHTML = '';
|
||
if (!S.vault.snippets.length) box.append(h(`<div style="color:var(--muted);padding:8px">${T('No snippets yet.')}</div>`));
|
||
S.vault.snippets.forEach((s) => {
|
||
const it = h(`<div class="it"><div>${esc(s.label)}</div><div class="mono">${esc(s.command)}</div></div>`);
|
||
it.onclick = () => runSnippet(s, this);
|
||
box.append(it);
|
||
});
|
||
}
|
||
|
||
toggleFind(open) {
|
||
const fb = $('.findbar', this.el);
|
||
const o = open ?? !fb.classList.contains('open');
|
||
fb.classList.toggle('open', o);
|
||
if (o) { $('input', fb).focus(); $('input', fb).select(); } else { this.search.clearDecorations?.(); this.term.focus(); }
|
||
}
|
||
|
||
async paste() { const t = await api.call('clipboard:read'); if (t) this.term.paste(t); }
|
||
sendText(t) { if (this.alive) api.ssh.write(this.id, t); }
|
||
zoom(k) {
|
||
const s = this.term.options.fontSize;
|
||
this.term.options.fontSize = k === '0' ? settings().fontSize : Math.max(8, Math.min(32, s + (k === '-' ? -1 : 1)));
|
||
this.doFit();
|
||
}
|
||
doFit() { try { this.fit.fit(); } catch {} }
|
||
onShow() { this.doFit(); this.term.focus(); }
|
||
applySettings() {
|
||
const st = settings();
|
||
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: termTheme() });
|
||
$('.term', this.el).style.background = termTheme().background;
|
||
this.doFit();
|
||
}
|
||
|
||
showOverlay(msg, err) {
|
||
if (!this.overlay) { this.overlay = h('<div class="overlay"></div>'); $('.term-wrap', this.el).append(this.overlay); }
|
||
this.overlay.innerHTML = err
|
||
? `<div class="msg err">${esc(msg)}</div><div class="row"><button class="btn" data-o="edit">${T('Edit host')}</button><button class="btn primary" data-o="retry">${ICONS.refresh}${T('Reconnect')}</button></div>`
|
||
: `<div class="spinner"></div><div class="msg">${esc(msg)}</div><button class="btn ghost" data-o="cancel">${T('Cancel')}</button>`;
|
||
this.overlay.onclick = (e) => {
|
||
const o = e.target.closest('[data-o]')?.dataset.o;
|
||
if (o === 'retry') this.reconnect();
|
||
if (o === 'cancel') closeTab(this.id);
|
||
if (o === 'edit' && this.host.id) { activateTab('home'); editHost(S.vault.hosts.find((x) => x.id === this.host.id) || this.host); }
|
||
};
|
||
}
|
||
hideOverlay() { this.overlay?.remove(); this.overlay = null; }
|
||
|
||
async connect() {
|
||
this.ended = false;
|
||
setTabState(this, 'wait');
|
||
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
|
||
this.doFit();
|
||
try {
|
||
await api.call('ssh:open', this.id, hostRef(this.host), { cols: this.term.cols, rows: this.term.rows });
|
||
this.alive = true;
|
||
this.hideOverlay();
|
||
setTabState(this, 'on');
|
||
this.doFit();
|
||
api.ssh.resize(this.id, this.term.cols, this.term.rows);
|
||
if (S.active === this.id) this.term.focus();
|
||
} catch (e) {
|
||
setTabState(this, 'err');
|
||
this.showOverlay(e.message, true);
|
||
}
|
||
}
|
||
reconnect() {
|
||
api.ssh.close(this.id);
|
||
this.alive = false;
|
||
this.term.write(`\r\n\x1b[90m— ${T('Reconnecting')} —\x1b[0m\r\n`);
|
||
this.connect();
|
||
}
|
||
onEvent(type, payload) {
|
||
if (type === 'data') this.term.write(payload);
|
||
else if (type === 'status' && this.overlay) $('.msg', this.overlay).textContent = payload;
|
||
else if (type === 'error') this.term.write(`\r\n\x1b[31m${payload}\x1b[0m\r\n`);
|
||
else if (type === 'closed') {
|
||
if (!this.alive) return;
|
||
this.alive = false; this.ended = true;
|
||
setTabState(this, '');
|
||
this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
|
||
}
|
||
}
|
||
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
|
||
}
|
||
function openTerminal(host) { return new TerminalSession(host); }
|
||
|
||
// ============================================================ RDP-Session (eingebettet im Tab)
|
||
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
|
||
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
|
||
S.covered = false;
|
||
function updateCover() {
|
||
const covered = !!document.querySelector('.modal-bg, .ctx, .lock-screen');
|
||
if (covered === S.covered) return;
|
||
S.covered = covered;
|
||
const t = S.tabs.find((x) => x.id === S.active && x.kind === 'rdp');
|
||
if (t) covered ? t.onHide() : t.onShow();
|
||
}
|
||
new MutationObserver(updateCover).observe(document.body, { childList: true, subtree: true });
|
||
|
||
class RdpSession {
|
||
constructor(host) {
|
||
this.kind = 'rdp';
|
||
this.id = uid();
|
||
this.host = host;
|
||
this.title = host.label || host.address;
|
||
this.attached = false;
|
||
this.el = h(`<div class="session">
|
||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span></div>
|
||
<button class="btn ghost sm" data-a="reconnect" title="${T('Reconnect')}">${ICONS.refresh}${T('Reconnect')}</button>
|
||
${api.platform === 'win32' ? `<button class="btn ghost sm" data-a="detach" title="${T('Continue session in a separate window')}">${ICONS.screen}${T('Detach as window')}</button>` : ''}
|
||
</div>
|
||
<div class="term-wrap rdp-area"><div class="rdp-hint">${T('RDP session hidden while a dialog is open.')}</div></div></div>`);
|
||
this.area = $('.rdp-area', this.el);
|
||
this.el.addEventListener('click', (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'reconnect') this.reconnect();
|
||
if (a === 'detach') this.detach();
|
||
});
|
||
let raf = 0;
|
||
this.ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => this.pushBounds()); });
|
||
this.ro.observe(this.area);
|
||
this.unsub = api.on('rdp:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
|
||
addTab(this);
|
||
this.connect();
|
||
}
|
||
|
||
bounds() {
|
||
const r = this.area.getBoundingClientRect();
|
||
const d = window.devicePixelRatio || 1;
|
||
return { x: Math.round(r.left * d), y: Math.round(r.top * d), width: Math.round(r.width * d), height: Math.round(r.height * d) };
|
||
}
|
||
pushBounds() { if (this.attached && this.area.offsetWidth) api.rdp.bounds(this.id, this.bounds()); }
|
||
isVisible() { return S.active === this.id && !S.covered; }
|
||
|
||
showOverlay(msg, err) {
|
||
this.overlay?.remove();
|
||
this.overlay = h(`<div class="overlay">${err ? '' : '<div class="spinner"></div>'}<div class="msg ${err ? 'err' : ''}">${esc(msg)}</div>
|
||
<div class="row">${err ? `<button class="btn" data-o="window">${ICONS.screen}${T('In separate window')}</button><button class="btn primary" data-o="retry">${ICONS.refresh}${T('Reconnect')}</button>` : `<button class="btn ghost" data-o="cancel">${T('Cancel')}</button>`}</div></div>`);
|
||
this.overlay.onclick = (e) => {
|
||
const o = e.target.closest('[data-o]')?.dataset.o;
|
||
if (o === 'retry') this.reconnect();
|
||
if (o === 'cancel') closeTab(this.id);
|
||
if (o === 'window') { launchRdp(this.host); closeTab(this.id); }
|
||
};
|
||
this.area.append(this.overlay);
|
||
}
|
||
|
||
async connect() {
|
||
setTabState(this, 'wait');
|
||
this.showOverlay(T('Connecting to {host} … Login or certificate prompts may appear in a separate window.', { host: this.host.address }));
|
||
await new Promise((r) => requestAnimationFrame(r));
|
||
try {
|
||
const ok = await api.call('rdp:open', this.id, this.host.id || this.host, this.bounds());
|
||
if (!ok && !this.attached && !this.disposed && !this.exited) this.showOverlay(T('The RDP window could not be embedded.'), true);
|
||
} catch (e) {
|
||
setTabState(this, 'err');
|
||
this.showOverlay(e.message, true);
|
||
}
|
||
}
|
||
|
||
onEvent(type, payload) {
|
||
if (type === 'attached') {
|
||
this.attached = true; this.exited = false;
|
||
this.overlay?.remove(); this.overlay = null;
|
||
setTabState(this, 'on');
|
||
if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id);
|
||
} else if (type === 'exit') {
|
||
this.attached = false; this.exited = true;
|
||
setTabState(this, payload?.code ? 'err' : '');
|
||
this.showOverlay((payload?.code ? T('RDP session ended (code {code}).', { code: payload.code }) : T('RDP session ended.')) + (payload?.message ? ' ' + payload.message : ''), true);
|
||
}
|
||
}
|
||
|
||
reconnect() { api.rdp.close(this.id); this.attached = false; this.exited = false; this.connect(); }
|
||
async detach() {
|
||
if (!this.attached) return;
|
||
if (await api.call('rdp:detach', this.id)) { this.attached = false; closeTab(this.id); toast(T('Session continues in a separate window'), 'ok'); }
|
||
}
|
||
onShow() { if (this.attached && this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } }
|
||
onHide() { if (this.attached) api.rdp.hide(this.id); }
|
||
dispose() { this.disposed = true; this.unsub(); this.ro.disconnect(); api.rdp.close(this.id); }
|
||
}
|
||
|
||
// ============================================================ SFTP
|
||
function viewSftpPicker(page) {
|
||
page.append(toolbar(T('Choose a host for SFTP …')));
|
||
const c = h('<div class="content"></div>'); page.append(c);
|
||
const list = S.vault.hosts.filter((x) => x.protocol !== 'rdp' && matches(x.label, x.address, x.username));
|
||
if (!list.length) return c.append(emptyState('folder', T('No SSH hosts'), T('SFTP requires an SSH host.'), T('New host'), () => editHost({})));
|
||
c.append(h(`<div class="section-title">${T('Open host in SFTP browser')}</div>`));
|
||
const grid = h('<div class="grid"></div>');
|
||
list.forEach((hst) => {
|
||
const card = h(`<div class="card">${avatar(hst)}<div class="meta"><div class="title">${esc(hst.label || hst.address)}</div><div class="sub">${esc(hostSub(hst))}</div></div></div>`);
|
||
card.onclick = () => openSftp(hst);
|
||
grid.append(card);
|
||
});
|
||
c.append(grid);
|
||
}
|
||
|
||
class FilePane {
|
||
constructor(sftp, side) {
|
||
this.sftp = sftp; this.side = side; this.cwd = ''; this.files = []; this.sel = new Set(); this.sort = { k: 'name', dir: 1 };
|
||
this.el = h(`<div class="pane"><div class="phead"><span class="title">${side === 'local' ? ICONS.screen + T('Local') : ICONS.folder + esc(sftp.host.label || sftp.host.address)}</span>
|
||
${side === 'local' && api.platform === 'win32' ? '<select class="roots"></select>' : ''}
|
||
<button class="btn ghost sm" data-p="up" title="${T('Parent folder')}">${ICONS.up}</button>
|
||
<button class="btn ghost sm" data-p="home" title="Home">${ICONS.home}</button>
|
||
<input class="path" spellcheck="false"/>
|
||
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
|
||
<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button></div>
|
||
<div class="files"></div></div>`);
|
||
this.el.addEventListener('click', (e) => {
|
||
const p = e.target.closest('[data-p]')?.dataset.p;
|
||
if (p === 'up') this.up();
|
||
if (p === 'home') this.go(this.home);
|
||
if (p === 'refresh') this.go(this.cwd);
|
||
if (p === 'mkdir') this.mkdir();
|
||
if (p === 'xfer') this.transferSelected();
|
||
});
|
||
$('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); });
|
||
const files = $('.files', this.el);
|
||
files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), icon: 'refresh', run: () => this.go(this.cwd) }]); } });
|
||
// Drag & Drop zwischen den Panes
|
||
this.el.addEventListener('dragover', (e) => { if (S.drag && S.drag.pane !== this) { e.preventDefault(); this.el.classList.add('dragover'); } });
|
||
this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); });
|
||
this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el.classList.remove('dragover'); if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); S.drag = null; });
|
||
}
|
||
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
|
||
async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); }
|
||
async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); }
|
||
async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); }
|
||
async init(home) {
|
||
this.home = home;
|
||
if (this.side === 'local' && api.platform === 'win32') {
|
||
const sel = $('.roots', this.el);
|
||
(await api.call('local:roots')).forEach((r) => sel.append(h(`<option>${esc(r)}</option>`)));
|
||
sel.onchange = () => this.go(sel.value);
|
||
}
|
||
return this.go(home);
|
||
}
|
||
async up() { this.go(await this.parent(this.cwd)); }
|
||
async go(dir) {
|
||
try {
|
||
const files = await this.list(dir);
|
||
this.cwd = dir; this.files = files; this.sel.clear();
|
||
$('.path', this.el).value = dir;
|
||
const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 2).toUpperCase() + '\\';
|
||
this.draw();
|
||
} catch (e) { toast(e.message, 'error'); }
|
||
}
|
||
sorted() {
|
||
const { k, dir } = this.sort;
|
||
return [...this.files].filter((f) => f.name !== '.' && f.name !== '..').sort((a, b) => (b.isDir - a.isDir) || (k === 'name' ? a.name.localeCompare(b.name, undefined, { numeric: true }) : (a[k] - b[k])) * dir);
|
||
}
|
||
draw() {
|
||
const box = $('.files', this.el);
|
||
const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : '');
|
||
const rows = this.view = this.sorted();
|
||
box.innerHTML = `<table><thead><tr><th data-s="name">Name${arrow('name')}</th><th data-s="size" style="text-align:right">${T('Size')}${arrow('size')}</th><th data-s="mtime">${T('Modified')}${arrow('mtime')}</th></tr></thead><tbody>
|
||
<tr data-up="1"><td class="name"><div><span class="ico-dir">${ICONS.folder}</span><span>..</span></div></td><td></td><td></td></tr>
|
||
${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><div><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></div></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`;
|
||
$$('th', box).forEach((th) => (th.onclick = () => { const k = th.dataset.s; this.sort = { k, dir: this.sort.k === k ? -this.sort.dir : 1 }; this.draw(); }));
|
||
$('tr[data-up]', box).ondblclick = () => this.up();
|
||
$$('tr[data-i]', box).forEach((tr) => {
|
||
const f = rows[Number(tr.dataset.i)];
|
||
tr.onclick = (e) => {
|
||
if (e.ctrlKey) this.sel.has(f.name) ? this.sel.delete(f.name) : this.sel.add(f.name);
|
||
else if (e.shiftKey && this.lastIdx != null) { const [a, b] = [this.lastIdx, Number(tr.dataset.i)].sort((x, y) => x - y); rows.slice(a, b + 1).forEach((x) => this.sel.add(x.name)); }
|
||
else { this.sel.clear(); this.sel.add(f.name); }
|
||
this.lastIdx = Number(tr.dataset.i);
|
||
$$('tr[data-i]', box).forEach((r) => r.classList.toggle('sel', this.sel.has(rows[Number(r.dataset.i)].name)));
|
||
};
|
||
tr.ondblclick = async () => {
|
||
if (f.isDir) this.go(await this.join(this.cwd, f.name));
|
||
else if (this.side === 'local') api.call('local:op', 'open', await this.join(this.cwd, f.name));
|
||
else { this.sel = new Set([f.name]); this.transferSelected(); }
|
||
};
|
||
tr.ondragstart = (e) => { if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); } S.drag = { pane: this }; e.dataTransfer.effectAllowed = 'copy'; };
|
||
tr.oncontextmenu = (e) => {
|
||
e.preventDefault();
|
||
if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); this.draw(); }
|
||
ctxMenu(e.clientX, e.clientY, [
|
||
{ label: this.side === 'local' ? T('Upload') : T('Download'), icon: this.side === 'local' ? 'upload' : 'download', run: () => this.transferSelected() },
|
||
...(f.isDir ? [{ label: T('Open'), icon: 'folder', run: async () => this.go(await this.join(this.cwd, f.name)) }] : []),
|
||
{ label: T('Rename'), icon: 'edit', run: () => this.rename(f) },
|
||
{ label: T('Copy path'), icon: 'copy', run: async () => api.call('clipboard:write', await this.join(this.cwd, f.name)) },
|
||
...(this.side === 'remote' && !f.isDir ? [{ label: T('Permissions (chmod)…'), icon: 'lock', run: () => this.chmod(f) }] : []),
|
||
'-', { label: this.side === 'local' ? T('Move to trash') : T('Delete'), icon: 'trash', danger: true, run: () => this.remove() },
|
||
]);
|
||
};
|
||
});
|
||
}
|
||
async mkdir() {
|
||
const n = await promptBox(T('New folder'), T('Name')); if (!n) return;
|
||
const p = await this.join(this.cwd, n);
|
||
try { this.side === 'local' ? await api.call('local:op', 'mkdir', p) : await api.call('sftp:op', this.sftp.id, 'mkdir', p); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
|
||
}
|
||
async rename(f) {
|
||
const n = await promptBox(T('Rename'), T('New name'), f.name); if (!n || n === f.name) return;
|
||
const a = await this.join(this.cwd, f.name), b = await this.join(this.cwd, n);
|
||
try { this.side === 'local' ? await api.call('local:op', 'rename', a, b) : await api.call('sftp:op', this.sftp.id, 'rename', a, b); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
|
||
}
|
||
async chmod(f) {
|
||
const m = await promptBox(T('Change permissions'), T('Octal (e.g. 644)'), (f.mode & 0o777).toString(8)); if (!m) return;
|
||
try { await api.call('sftp:op', this.sftp.id, 'chmod', await this.join(this.cwd, f.name), parseInt(m, 8)); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
|
||
}
|
||
async remove() {
|
||
const names = [...this.sel]; if (!names.length) return;
|
||
if (!(await confirmBox(T('Delete?'), T(this.side === 'local' ? 'Move {n} item(s) to the trash: {list}' : 'Permanently delete {n} item(s) from the server: {list}', { n: names.length, list: names.slice(0, 5).join(', ') + (names.length > 5 ? ' …' : '') })))) return;
|
||
for (const n of names) {
|
||
const f = this.files.find((x) => x.name === n);
|
||
const p = await this.join(this.cwd, n);
|
||
try {
|
||
if (this.side === 'local') await api.call('local:op', 'delete', p);
|
||
else await api.call('sftp:op', this.sftp.id, f.isDir ? 'rmdir' : 'unlink', p);
|
||
} catch (e) { toast(`${n}: ${e.message}`, 'error'); }
|
||
}
|
||
this.go(this.cwd);
|
||
}
|
||
async transferSelected() {
|
||
const names = [...this.sel];
|
||
if (!names.length) return toast(T('Nothing selected'));
|
||
const target = this.other;
|
||
for (const n of names) {
|
||
const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n);
|
||
const dir = this.side === 'local' ? 'upload' : 'download';
|
||
await this.sftp.transfer(dir, dir === 'upload' ? src : dst, dir === 'upload' ? dst : src, n);
|
||
}
|
||
target.go(target.cwd);
|
||
}
|
||
}
|
||
|
||
class SftpSession {
|
||
constructor(host) {
|
||
this.kind = 'sftp'; this.id = uid(); this.host = host;
|
||
this.title = `SFTP · ${host.label || host.address}`;
|
||
this.el = h(`<div class="session"><div class="sftp"></div><div class="transfers"></div></div>`);
|
||
this.local = new FilePane(this, 'local');
|
||
this.remote = new FilePane(this, 'remote');
|
||
const wrap = $('.sftp', this.el);
|
||
wrap.append(this.local.el, h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div>`));
|
||
this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total));
|
||
this.xfers = new Map();
|
||
addTab(this);
|
||
this.open();
|
||
}
|
||
async open() {
|
||
this.local.init(await api.call('local:home'));
|
||
try {
|
||
const { home } = await api.call('sftp:open', this.id, this.host.id || this.host);
|
||
$('.sftp', this.el).lastElementChild.replaceWith(this.remote.el);
|
||
await this.remote.init(home);
|
||
setTabState(this, 'on');
|
||
} catch (e) {
|
||
setTabState(this, 'err');
|
||
const pe = $('.pane-empty', this.el);
|
||
pe.innerHTML = `<div style="color:var(--red);max-width:420px;text-align:center">${esc(e.message)}</div>`;
|
||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||
b.onclick = () => { closeTab(this.id); openSftp(this.host); };
|
||
pe.append(b);
|
||
}
|
||
}
|
||
async transfer(dir, localPath, remotePath, name) {
|
||
const tid = uid();
|
||
const row = h(`<div class="transfer"><span>${dir === 'upload' ? ICONS.upload : ICONS.download}</span><span class="nm">${esc(name)}</span><span class="pct">0%</span><div class="bar"><i></i></div></div>`);
|
||
$('.transfers', this.el).prepend(row);
|
||
this.xfers.set(tid, row);
|
||
try {
|
||
await api.call('sftp:transfer', this.id, dir, localPath, remotePath, tid);
|
||
row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done');
|
||
setTimeout(() => row.remove(), 5000);
|
||
} catch (e) {
|
||
row.classList.add('fail'); $('.pct', row).textContent = T('Error'); row.title = e.message; toast(`${name}: ${e.message}`, 'error');
|
||
setTimeout(() => row.remove(), 10000);
|
||
}
|
||
}
|
||
progress(tid, done, total) {
|
||
const row = this.xfers.get(tid); if (!row) return;
|
||
const p = total ? Math.round((done / total) * 100) : 0;
|
||
$('.bar i', row).style.width = p + '%';
|
||
$('.pct', row).textContent = `${p}% · ${fmtSize(done)} / ${fmtSize(total)}`;
|
||
}
|
||
dispose() { this.unsub(); api.call('sftp:close', this.id).catch(() => {}); }
|
||
}
|
||
function openSftp(host) { return new SftpSession(host); }
|
||
|
||
// Gespeicherte Hosts per ID übergeben; Container-Shells mit Zusatzbefehl
|
||
const hostRef = (host) => (host.id ? (host.execCommand ? { ref: host.id, execCommand: host.execCommand, label: host.label } : host.id) : host);
|
||
|
||
// ============================================================ Docker (Container über SSH)
|
||
class DockerSession {
|
||
constructor(host) {
|
||
this.kind = 'docker'; this.id = uid(); this.host = host;
|
||
this.title = `Docker · ${host.label || host.address}`;
|
||
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
|
||
this.el = h(`<div class="session docker">
|
||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search containers …'))}"/></label>
|
||
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
|
||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||
</div>
|
||
<div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
|
||
this.body = $('.docker-body', this.el);
|
||
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
|
||
this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); });
|
||
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
|
||
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||
addTab(this);
|
||
this.open();
|
||
}
|
||
|
||
async open() {
|
||
try {
|
||
this.apply(await api.call('docker:open', this.id, hostRef(this.host)));
|
||
setTabState(this, 'on');
|
||
this.loadStats();
|
||
this.timer = setInterval(() => { if (S.active === this.id && !$('.modal-bg')) this.refresh(); }, 10000);
|
||
} catch (e) {
|
||
setTabState(this, 'err');
|
||
this.error(e.message);
|
||
}
|
||
}
|
||
|
||
error(msg) {
|
||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:520px;text-align:center">${esc(msg)}</div></div>`;
|
||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||
b.onclick = () => { closeTab(this.id); openDocker(this.host); };
|
||
$('.pane-empty', this.body).append(b);
|
||
}
|
||
|
||
apply(r) {
|
||
this.containers = r.containers;
|
||
$('.rt', this.el).textContent = ` · ${r.runtime === 'podman' ? 'Podman' : 'Docker'}${r.sudo ? ' (sudo)' : ''}`;
|
||
this.draw();
|
||
}
|
||
|
||
async refresh(withStats) {
|
||
if (this.busy) return;
|
||
this.busy = true;
|
||
try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); }
|
||
this.busy = false;
|
||
}
|
||
|
||
async loadStats() {
|
||
try { this.stats = await api.call('docker:stats', this.id); this.draw(); } catch {}
|
||
}
|
||
|
||
draw() {
|
||
const running = (c) => c.state === 'running';
|
||
const list = this.containers
|
||
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
|
||
.sort((a, b) => (running(b) - running(a)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||
if (!this.containers.length) { this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--muted)">${T('No containers on this host.')}</div></div>`; return; }
|
||
const scroll = this.body.scrollTop;
|
||
this.body.innerHTML = `<table class="docker-table"><thead><tr><th></th><th>${T('Name')}</th><th>${T('Status')}</th><th>${T('Ports')}</th><th class="num">CPU</th><th class="num">${T('Memory')}</th><th></th></tr></thead><tbody>
|
||
${list.map((c) => {
|
||
const st = this.stats[c.id];
|
||
const on = running(c);
|
||
return `<tr data-id="${esc(c.id)}"><td><span class="dot ${on ? 'on' : c.state === 'paused' || c.state === 'restarting' ? 'wait' : ''}"></span></td>
|
||
<td class="name"><div><b>${esc(c.name)}</b><span class="img">${esc(c.image)}</span></div></td>
|
||
<td class="muted">${esc(c.status)}</td><td class="muted ports" title="${esc(c.ports)}">${esc(c.ports)}</td>
|
||
<td class="num">${on && st ? esc(st.cpu) : ''}</td><td class="num">${on && st ? esc(st.mem.split('/')[0].trim()) : ''}</td>
|
||
<td class="acts">
|
||
${on ? `<button class="btn ghost sm" data-c="shell" title="${T('Open shell')}">${ICONS.term}</button>` : ''}
|
||
<button class="btn ghost sm" data-c="logs" title="${T('Logs')}">${ICONS.logs}</button>
|
||
${on ? `<button class="btn ghost sm" data-c="restart" title="${T('Restart')}">${ICONS.refresh}</button><button class="btn ghost sm" data-c="stop" title="${T('Stop')}">${ICONS.stop}</button>`
|
||
: `<button class="btn ghost sm" data-c="start" title="${T('Start')}">${ICONS.play}</button><button class="btn ghost sm" data-c="remove" title="${T('Delete')}">${ICONS.trash}</button>`}
|
||
</td></tr>`;
|
||
}).join('')}</tbody></table>`;
|
||
this.body.scrollTop = scroll;
|
||
$$('tr[data-id]', this.body).forEach((tr) => {
|
||
const c = this.containers.find((x) => x.id === tr.dataset.id);
|
||
tr.onclick = (e) => { const a = e.target.closest('[data-c]')?.dataset.c; if (a) this.act(a, c, e.target.closest('button')); };
|
||
tr.ondblclick = (e) => { if (!e.target.closest('[data-c]') && running(c)) this.act('shell', c); };
|
||
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||
...(running(c) ? [{ label: T('Open shell'), icon: 'term', run: () => this.act('shell', c) }] : []),
|
||
{ label: T('Logs'), icon: 'logs', run: () => this.act('logs', c) },
|
||
'-',
|
||
...(running(c) ? [{ label: T('Restart'), icon: 'refresh', run: () => this.act('restart', c) }, { label: T('Stop'), icon: 'stop', run: () => this.act('stop', c) }]
|
||
: [{ label: T('Start'), icon: 'play', run: () => this.act('start', c) }]),
|
||
{ label: T('Copy ID'), icon: 'copy', run: () => api.call('clipboard:write', c.id) },
|
||
'-',
|
||
{ label: T('Delete'), icon: 'trash', danger: true, run: () => this.act('remove', c) },
|
||
]);
|
||
});
|
||
}
|
||
|
||
async act(a, c, btn) {
|
||
if (a === 'shell' || a === 'logs') {
|
||
const cmd = await call('docker:command', this.id, a, c.id);
|
||
openTerminal({ ...this.host, execCommand: cmd, label: `${a === 'logs' ? T('Logs') : '🐳'} ${c.name}` });
|
||
return;
|
||
}
|
||
if (a === 'remove' && !(await confirmBox(T('Delete container?'), T('“{name}” will be removed including its writable layer. Volumes are kept.', { name: c.name })))) return;
|
||
if (a === 'stop' && !(await confirmBox(T('Stop container?'), c.name, T('Stop')))) return;
|
||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner sm"></span>'; }
|
||
try { await call('docker:action', this.id, a, c.id); toast(T('{action}: {name}', { action: { start: T('Started'), stop: T('Stopped'), restart: T('Restarted'), remove: T('Deleted') }[a], name: c.name }), 'ok'); } catch {}
|
||
await this.refresh(true);
|
||
}
|
||
|
||
onShow() { if (this.containers.length) this.refresh(); }
|
||
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
|
||
}
|
||
function openDocker(host) { return new DockerSession(host); }
|
||
|
||
// ============================================================ Firewall (UFW / iptables über SSH)
|
||
class FirewallSession {
|
||
constructor(host) {
|
||
this.kind = 'firewall'; this.id = uid(); this.host = host;
|
||
this.title = `${T('Firewall')} · ${host.label || host.address}`;
|
||
this.backend = null; this.data = null;
|
||
this.el = h(`<div class="session firewall">
|
||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||
<div class="seg sm backends"></div>
|
||
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
|
||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
|
||
</div>
|
||
<div class="docker-body fw-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
|
||
this.body = $('.fw-body', this.el);
|
||
this.el.addEventListener('click', (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'refresh') this.refresh();
|
||
if (a === 'add') this.backend === 'ufw' ? this.addUfw() : this.addIpt();
|
||
if (a === 'save') this.op('ipt', 'save', {}, T('Rules saved permanently'));
|
||
});
|
||
this.unsub = api.on('firewall:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||
addTab(this);
|
||
this.open();
|
||
}
|
||
|
||
async open() {
|
||
try {
|
||
const r = await api.call('firewall:open', this.id, hostRef(this.host));
|
||
$('.rt', this.el).textContent = r.sudo ? ' · sudo' : ' · root';
|
||
const opts = [...(r.tools.includes('ufw') ? [['ufw', 'UFW']] : []), ...(r.tools.includes('iptables') ? [['iptables', 'iptables']] : []), ...(r.tools.includes('ip6tables') ? [['ip6tables', 'ip6tables']] : [])];
|
||
const seg = $('.backends', this.el);
|
||
opts.forEach(([id, l]) => { const b = h(`<button data-b="${id}">${l}</button>`); b.onclick = () => this.switchTo(id); seg.append(b); });
|
||
setTabState(this, 'on');
|
||
$('[data-a=add]', this.el).disabled = false;
|
||
await this.switchTo(opts[0][0]);
|
||
} catch (e) {
|
||
setTabState(this, 'err');
|
||
this.error(e.message);
|
||
}
|
||
}
|
||
|
||
error(msg) {
|
||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:560px;text-align:center">${esc(msg)}</div></div>`;
|
||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||
b.onclick = () => { closeTab(this.id); openFirewall(this.host); };
|
||
$('.pane-empty', this.body).append(b);
|
||
}
|
||
|
||
async switchTo(b) {
|
||
this.backend = b;
|
||
$$('.backends button', this.el).forEach((x) => x.classList.toggle('active', x.dataset.b === b));
|
||
$('[data-a=save]', this.el).style.display = b === 'ufw' ? 'none' : '';
|
||
$('[data-a=add]', this.el).disabled = false;
|
||
await this.refresh();
|
||
}
|
||
|
||
async refresh() {
|
||
try { this.data = await api.call('firewall:list', this.id, this.backend); this.draw(); } catch (e) { toast(e.message, 'error'); }
|
||
}
|
||
|
||
// Aktion ausführen, danach neu laden
|
||
async op(kind, op, args, okMsg) {
|
||
try { await call(kind === 'ufw' ? 'firewall:ufw' : 'firewall:ipt', this.id, op, args); if (okMsg) toast(okMsg, 'ok'); } catch { return false; }
|
||
await this.refresh();
|
||
return true;
|
||
}
|
||
|
||
sshPort() { return String(this.host.port || 22); }
|
||
|
||
draw() {
|
||
const scroll = this.body.scrollTop;
|
||
this.body.innerHTML = '';
|
||
if (this.backend === 'ufw') this.drawUfw(); else this.drawIpt();
|
||
this.body.scrollTop = scroll;
|
||
}
|
||
|
||
drawUfw() {
|
||
const d = this.data;
|
||
const on = d.status === 'active';
|
||
const head = h(`<div class="fw-head"><span class="status-pill ${on ? 'on' : ''}">${on ? T('active') : T('inactive')}</span></div>`);
|
||
const tgl = h(`<button class="btn sm ${on ? '' : 'primary'}">${on ? T('Disable') : T('Enable')}</button>`);
|
||
tgl.onclick = async () => {
|
||
if (on) { if (await confirmBox(T('Disable firewall?'), T('All UFW rules will stop filtering traffic.'), T('Disable'))) this.op('ufw', 'disable', {}); return; }
|
||
const sshOk = d.rules.some((r) => r.action !== 'DENY' && r.action !== 'REJECT' && r.dir === 'IN' && new RegExp(`(^|[^0-9])${this.sshPort()}(/tcp)?($|[^0-9])|OpenSSH|ssh`, 'i').test(r.to));
|
||
if (!sshOk) {
|
||
const r = await modal({ title: T('Enable firewall?'), text: T('There is no rule that allows SSH (port {port}). Enabling UFW could lock you out of this server.', { port: this.sshPort() }),
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Enable anyway'), value: 'force', cls: 'danger' }, { label: T('Allow SSH and enable'), value: 'ssh', cls: 'primary' }] });
|
||
if (!r) return;
|
||
if (r === 'ssh' && !(await this.op('ufw', 'add', { action: 'allow', dir: 'in', port: this.sshPort(), proto: 'tcp', comment: 'SSH (MrTerm)' }))) return;
|
||
}
|
||
this.op('ufw', 'enable', {}, T('Firewall enabled'));
|
||
};
|
||
head.append(tgl);
|
||
if (d.defaults) {
|
||
for (const dir of ['incoming', 'outgoing']) {
|
||
const sel = h(`<label class="fw-def">${dir === 'incoming' ? T('Incoming') : T('Outgoing')}<select>${['allow', 'deny', 'reject'].map((p) => `<option value="${p}" ${d.defaults[dir] === p ? 'selected' : ''}>${{ allow: T('Allow'), deny: T('Deny'), reject: T('Reject') }[p]}</option>`).join('')}</select></label>`);
|
||
$('select', sel).onchange = async (e) => {
|
||
const pol = e.target.value;
|
||
if (dir === 'incoming' && pol !== 'allow' && on && !(await confirmBox(T('Change default policy?'), T('Incoming connections without a matching rule will be blocked. Make sure SSH is allowed.'), T('Change')))) return this.draw();
|
||
this.op('ufw', 'default', { policy: pol, dir });
|
||
};
|
||
head.append(sel);
|
||
}
|
||
}
|
||
this.body.append(head);
|
||
$('[data-a=add]', this.el).disabled = !on;
|
||
if (!on) {
|
||
this.body.append(h(`<div class="pane-empty" style="height:auto;padding:48px 20px"><div class="fw-off">${ICONS.wall}<b>${T('UFW is disabled')}</b><span>${T('Enable UFW to view or add rules.')}</span></div></div>`));
|
||
return;
|
||
}
|
||
if (!d.rules.length) { this.body.append(h(`<div class="pane-empty" style="height:auto;padding:40px"><div style="color:var(--muted)">${T('No rules yet.')}</div></div>`)); return; }
|
||
const t = h(`<table class="docker-table fw-table"><thead><tr><th>#</th><th>${T('To')}</th><th>${T('Action')}</th><th>${T('From')}</th><th>${T('Comment')}</th><th></th></tr></thead><tbody></tbody></table>`);
|
||
for (const r of d.rules) {
|
||
const tr = h(`<tr><td class="num">${r.num}</td><td><b>${esc(r.to)}</b></td><td><span class="fw-act ${r.action.toLowerCase()}">${esc(r.action)} ${esc(r.dir)}</span></td><td class="muted">${esc(r.from)}</td><td class="muted">${esc(r.comment)}</td>
|
||
<td class="acts"><button class="btn ghost sm" title="${esc(T('Delete'))}">${ICONS.trash}</button></td></tr>`);
|
||
$('button', tr).onclick = async () => { if (await confirmBox(T('Delete rule?'), `${r.to} · ${r.action} ${r.dir} · ${r.from}`)) this.op('ufw', 'delete', { num: r.num }, T('Rule deleted')); };
|
||
$('tbody', t).append(tr);
|
||
}
|
||
this.body.append(t);
|
||
}
|
||
|
||
drawIpt() {
|
||
const fam = this.backend === 'ip6tables' ? 6 : 4;
|
||
const builtin = ['INPUT', 'FORWARD', 'OUTPUT'];
|
||
const chains = [...this.data.chains].sort((a, b) => ((builtin.indexOf(a.name) + 1 || 99) - (builtin.indexOf(b.name) + 1 || 99)));
|
||
this.body.append(h(`<div class="fw-note">${T('Changes apply immediately but are lost after a reboot unless you click “Save permanently”.')}</div>`));
|
||
for (const c of chains) {
|
||
const sec = h(`<div class="fw-chain"><div class="fw-chain-head"><b>${esc(c.name)}</b><span class="muted">${c.rules.length} ${T('rules')}</span></div></div>`);
|
||
if (c.policy && builtin.includes(c.name)) {
|
||
const sel = h(`<label class="fw-def">${T('Policy')}<select>${['ACCEPT', 'DROP'].map((p) => `<option ${c.policy === p ? 'selected' : ''}>${p}</option>`).join('')}</select></label>`);
|
||
$('select', sel).onchange = async (e) => {
|
||
if (e.target.value === 'DROP' && c.name !== 'FORWARD' && !(await confirmBox(T('Change default policy?'), T('Traffic without a matching ACCEPT rule will be dropped. Make sure SSH is allowed, or you may lock yourself out.'), T('Change')))) return this.draw();
|
||
this.op('ipt', 'policy', { family: fam, chain: c.name, policy: e.target.value });
|
||
};
|
||
$('.fw-chain-head', sec).append(sel);
|
||
}
|
||
if (c.rules.length) {
|
||
const t = h(`<table class="docker-table fw-table"><tbody></tbody></table>`);
|
||
for (const r of c.rules) {
|
||
const tgt = (r.spec.match(/-j (\S+)/) || [])[1] || '';
|
||
const tr = h(`<tr><td class="num" style="width:36px">${r.num}</td><td class="mono spec">${esc(r.spec)}</td><td style="width:90px"><span class="fw-act ${tgt === 'ACCEPT' ? 'allow' : /DROP|REJECT/.test(tgt) ? 'deny' : ''}">${esc(tgt)}</span></td>
|
||
<td class="acts" style="width:50px"><button class="btn ghost sm" title="${esc(T('Delete'))}">${ICONS.trash}</button></td></tr>`);
|
||
$('button', tr).onclick = async () => { if (await confirmBox(T('Delete rule?'), `${c.name} #${r.num}: ${r.spec}`)) this.op('ipt', 'delete', { family: fam, chain: c.name, num: r.num }, T('Rule deleted')); };
|
||
$('tbody', t).append(tr);
|
||
}
|
||
sec.append(t);
|
||
}
|
||
this.body.append(sec);
|
||
}
|
||
}
|
||
|
||
async addUfw() {
|
||
const r = await modal({ title: T('Add UFW rule'), body: `
|
||
<div class="row"><div class="field"><label>${T('Action')}</label><select name="action"><option value="allow">${T('Allow')}</option><option value="deny">${T('Deny')}</option><option value="reject">${T('Reject')}</option><option value="limit">${T('Limit (rate-limit)')}</option></select></div>
|
||
<div class="field"><label>${T('Direction')}</label><select name="dir"><option value="in">${T('Incoming')}</option><option value="out">${T('Outgoing')}</option></select></div></div>
|
||
<div class="row"><div class="field"><label>${T('Port')}</label><input name="port" placeholder="22, 80,443, 6000:6010"/></div>
|
||
<div class="field small"><label>${T('Protocol')}</label><select name="proto"><option value="any">${T('Any')}</option><option value="tcp">TCP</option><option value="udp">UDP</option></select></div></div>
|
||
<div class="field"><label>${T('From (IP or network)')}</label><input name="from" placeholder="any, 192.168.0.0/24"/></div>
|
||
<div class="field"><label>${T('Comment')}</label><input name="comment" placeholder="${esc(T('optional'))}"/></div>
|
||
<label class="check"><input type="checkbox" name="top"/>${T('Insert at the top (highest priority)')}</label>`,
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
|
||
if (!r) return;
|
||
this.op('ufw', 'add', { ...r, port: r.port.replace(/\s/g, ''), from: r.from.trim() || 'any', comment: r.comment.trim() }, T('Rule added'));
|
||
}
|
||
|
||
async addIpt() {
|
||
const fam = this.backend === 'ip6tables' ? 6 : 4;
|
||
const chains = this.data.chains.map((c) => c.name);
|
||
const r = await modal({ title: T('Add {bin} rule', { bin: this.backend }), body: `
|
||
<div class="row"><div class="field"><label>${T('Chain')}</label><select name="chain">${chains.map((c) => `<option ${c === 'INPUT' ? 'selected' : ''}>${esc(c)}</option>`).join('')}</select></div>
|
||
<div class="field"><label>${T('Target')}</label><select name="target"><option>ACCEPT</option><option>DROP</option><option>REJECT</option><option>LOG</option><option>RETURN</option></select></div></div>
|
||
<div class="row"><div class="field small"><label>${T('Protocol')}</label><select name="proto"><option value="tcp">TCP</option><option value="udp">UDP</option><option value="${fam === 6 ? 'icmpv6' : 'icmp'}">ICMP</option><option value="all">${T('Any')}</option></select></div>
|
||
<div class="field"><label>${T('Port')}</label><input name="port" placeholder="22, 80,443, 6000:6010"/></div></div>
|
||
<div class="row"><div class="field"><label>${T('Source (IP or network)')}</label><input name="source" placeholder="any, ${fam === 6 ? 'fd00::/8' : '192.168.0.0/24'}"/></div>
|
||
<div class="field small"><label>${T('Interface')}</label><input name="iface" placeholder="eth0"/></div></div>
|
||
<div class="field"><label>${T('Connection state')}</label><select name="state"><option value="">${T('Any')}</option><option value="NEW">NEW</option><option value="ESTABLISHED,RELATED">ESTABLISHED,RELATED</option></select></div>
|
||
<div class="field"><label>${T('Comment')}</label><input name="comment" placeholder="${esc(T('optional'))}"/></div>
|
||
<label class="check"><input type="checkbox" name="top" checked/>${T('Insert at the top (highest priority)')}</label>`,
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
|
||
if (!r) return;
|
||
this.op('ipt', 'add', { ...r, family: fam, port: r.port.replace(/\s/g, ''), source: r.source.trim(), iface: r.iface.trim(), comment: r.comment.trim() }, T('Rule added'));
|
||
}
|
||
|
||
dispose() { this.unsub(); api.call('firewall:close', this.id).catch(() => {}); }
|
||
}
|
||
function openFirewall(host) { return new FirewallSession(host); }
|
||
|
||
// ============================================================ Netzwerk (Ubuntu netplan / Debian ifupdown über SSH)
|
||
const NET_KINDS = { ethernet: 'Ethernet', bond: 'Bond', bridge: 'Bridge', vlan: 'VLAN' };
|
||
const BOND_MODES = [['802.3ad', '802.3ad (LACP)'], ['active-backup', 'active-backup'], ['balance-rr', 'balance-rr'], ['balance-xor', 'balance-xor'], ['balance-tlb', 'balance-tlb'], ['balance-alb', 'balance-alb'], ['broadcast', 'broadcast']];
|
||
const VIRTUAL_IF = /^(veth|docker\d|br-[0-9a-f]{12}|tap\d|fwbr|fwpr|fwln|vnet|virbr|cali|flannel|cni|kube|tun|wg|tailscale|zt)/;
|
||
const splitList = (v) => String(v || '').split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
|
||
|
||
class NetworkSession {
|
||
constructor(host) {
|
||
this.kind = 'network'; this.id = uid(); this.host = host;
|
||
this.title = `${T('Network')} · ${host.label || host.address}`;
|
||
this.data = null; this.showVirtual = false;
|
||
this.el = h(`<div class="session network">
|
||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label>
|
||
<button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button>
|
||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
|
||
</div>
|
||
<div class="docker-body net-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
|
||
this.body = $('.net-body', this.el);
|
||
this.el.addEventListener('click', (e) => {
|
||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||
if (a === 'refresh') this.refresh();
|
||
if (a === 'files') this.files();
|
||
if (a === 'new') ctxMenu(e.clientX, e.clientY, [
|
||
{ label: T('New bond'), icon: 'plus', run: () => this.edit(null, 'bond') },
|
||
{ label: T('New bridge'), icon: 'plus', run: () => this.edit(null, 'bridge') },
|
||
{ label: T('New VLAN'), icon: 'plus', run: () => this.edit(null, 'vlan') },
|
||
]);
|
||
});
|
||
$('[data-a=virtual]', this.el).onchange = (e) => { this.showVirtual = e.target.checked; this.draw(); };
|
||
this.unsub = api.on('network:closed', (sid) => { if (sid === this.id && !this.applying) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||
addTab(this);
|
||
this.open();
|
||
}
|
||
|
||
async open() {
|
||
try {
|
||
this.apply(await api.call('network:open', this.id, hostRef(this.host)));
|
||
setTabState(this, 'on');
|
||
} catch (e) { setTabState(this, 'err'); this.error(e.message); }
|
||
}
|
||
|
||
error(msg) {
|
||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:560px;text-align:center">${esc(msg)}</div></div>`;
|
||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||
b.onclick = () => { closeTab(this.id); openNetwork(this.host); };
|
||
$('.pane-empty', this.body).append(b);
|
||
}
|
||
|
||
apply(d) {
|
||
this.data = d;
|
||
const be = { netplan: 'netplan', ifupdown: 'ifupdown', networkmanager: 'NetworkManager', networkd: 'systemd-networkd' }[d.backend] || d.backend;
|
||
$('.rt', this.el).textContent = ` · ${d.os || ''} · ${be}`;
|
||
$('[data-a=new]', this.el).disabled = !d.editable;
|
||
this.draw();
|
||
}
|
||
|
||
async refresh() {
|
||
try { this.apply(await api.call('network:read', this.id)); } catch (e) { toast(e.message, 'error'); }
|
||
}
|
||
|
||
cfg(name) { return this.data.config.find((c) => c.name === name); }
|
||
|
||
draw() {
|
||
const d = this.data;
|
||
const scroll = this.body.scrollTop;
|
||
this.body.innerHTML = '';
|
||
const wrap = h('<div class="net-wrap"></div>');
|
||
if (d.rolledBack) wrap.append(h(`<div class="net-banner warn">${ICONS.refresh}<span>${esc(T('The last network change was rolled back automatically ({time}) because MrTerm could not reconnect.', { time: d.rolledBack }))}</span></div>`));
|
||
if (!d.editable) wrap.append(h(`<div class="net-banner">${ICONS.network}<span>${esc(d.note || T('Editing is supported for Ubuntu (netplan) and Debian/Proxmox (ifupdown). This host uses {backend}, so interfaces are shown read-only. Config files can still be edited.', { backend: d.backend }))}</span></div>`));
|
||
|
||
// System: Hostname, Gateway, DNS
|
||
const gw = d.routes.map((r) => `${esc(r.via || '—')} <span class="muted">(${esc(r.dev)})</span>`).join('<br>') || `<span class="muted">${T('none')}</span>`;
|
||
const sys = h(`<div class="net-sys">
|
||
<div><label>${T('Hostname')}</label><div class="v"><b>${esc(d.hostname)}</b><button class="btn ghost sm" data-s="host">${ICONS.edit}</button></div></div>
|
||
<div><label>${T('Default gateway')}</label><div class="v">${gw}</div></div>
|
||
<div><label>${T('DNS servers')}</label><div class="v">${esc(d.dns.servers.join(', ') || '—')}${d.dns.search.length ? `<br><span class="muted">${T('Search')}: ${esc(d.dns.search.join(' '))}</span>` : ''}${d.dns.editable && !d.dns.servers.includes('127.0.0.53') ? `<button class="btn ghost sm" data-s="dns">${ICONS.edit}</button>` : ''}</div>
|
||
${d.dns.servers.includes('127.0.0.53') ? `<div class="hint">${T('systemd-resolved is used: set DNS servers per interface.')}</div>` : ''}</div></div>`);
|
||
sys.onclick = (e) => { const a = e.target.closest('[data-s]')?.dataset.s; if (a === 'host') this.hostname(); if (a === 'dns') this.resolv(); };
|
||
wrap.append(sys);
|
||
|
||
// Schnittstellen: live + nur konfigurierte
|
||
const live = d.interfaces.filter((i) => this.showVirtual || !VIRTUAL_IF.test(i.name));
|
||
const names = new Set(live.map((i) => i.name));
|
||
const cfgOnly = d.config.filter((c) => !d.interfaces.some((i) => i.name === c.name)).map((c) => ({ name: c.name, kind: c.kind, state: 'ABSENT', addrs: [], master: '' }));
|
||
const order = { bond: 1, bridge: 2, vlan: 3, ethernet: 0 };
|
||
const list = [...live, ...cfgOnly.filter((c) => !names.has(c.name))].sort((a, b) => ((order[this.kindOf(a)] ?? 4) - (order[this.kindOf(b)] ?? 4)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||
const grid = h('<div class="net-grid"></div>');
|
||
for (const i of list) grid.append(this.card(i));
|
||
wrap.append(grid);
|
||
this.body.append(wrap);
|
||
this.body.scrollTop = scroll;
|
||
}
|
||
|
||
kindOf(i) { return this.cfg(i.name)?.kind || (['bond', 'bridge', 'vlan'].includes(i.kind) ? i.kind : 'ethernet'); }
|
||
|
||
card(i) {
|
||
const c = this.cfg(i.name);
|
||
const kind = this.kindOf(i);
|
||
const up = i.state === 'UP' || i.state === 'UNKNOWN';
|
||
const summary = !c ? `<span class="muted">${T('Not configured')}</span>`
|
||
: c.method4 === 'dhcp' ? 'DHCP' : c.method4 === 'static' ? `${T('Static')} ${esc(c.addresses.join(', '))}${c.gateway ? ` → ${esc(c.gateway)}` : ''}` : `<span class="muted">${T('No IPv4')}</span>`;
|
||
const b = i.bonding;
|
||
const bondInfo = b ? `<div class="net-bond"><div><span class="muted">${T('Mode')}:</span> ${esc(b.mode || '')}${b.lacpRate ? ` · LACP ${esc(b.lacpRate)}` : ''}${b.hashPolicy ? ` · ${esc(b.hashPolicy)}` : ''}</div>
|
||
${b.slaves.map((s) => `<div class="slave"><span class="dot ${s.mii === 'up' ? 'on' : 'err'}"></span>${esc(s.name)} <span class="muted">${esc(s.speed || '')}${s.aggregator ? ` · Agg ${esc(s.aggregator)}` : ''}</span></div>`).join('')}</div>` : '';
|
||
const el = h(`<div class="net-card ${i.state === 'ABSENT' ? 'absent' : ''}">
|
||
<div class="nh"><span class="dot ${i.state === 'ABSENT' ? '' : up ? 'on' : 'err'}"></span><b>${esc(i.name)}</b><span class="kind">${NET_KINDS[kind] || esc(i.kind || '')}</span>
|
||
${i.master ? `<span class="muted">→ ${esc(i.master)}</span>` : ''}<span class="grow"></span>
|
||
${this.data.editable ? `<button class="btn ghost sm" data-n="edit" title="${T('Edit')}">${ICONS.edit}</button>` : ''}
|
||
${this.data.editable && c && kind !== 'ethernet' ? `<button class="btn ghost sm" data-n="del" title="${T('Delete')}">${ICONS.trash}</button>` : ''}</div>
|
||
<div class="nm muted">${i.mac ? esc(i.mac) : ''}${i.mtu ? ` · MTU ${i.mtu}` : ''}${i.state === 'ABSENT' ? T('Configured, but not present') : ''}</div>
|
||
<div class="na">${i.addrs.map((a) => `<div>${esc(a.addr)}${a.dynamic ? ' <span class="tag">DHCP</span>' : ''}</div>`).join('') || `<div class="muted">${T('No address')}</div>`}</div>
|
||
${bondInfo}
|
||
<div class="ncfg"><span class="muted">${T('Configured')}:</span> ${summary}</div></div>`);
|
||
el.onclick = (e) => {
|
||
const a = e.target.closest('[data-n]')?.dataset.n;
|
||
if (a === 'edit') this.edit(i.name, kind);
|
||
if (a === 'del') this.remove(c);
|
||
};
|
||
return el;
|
||
}
|
||
|
||
// Anwenden mit Rollback-Anzeige
|
||
async run(label, fn) {
|
||
this.applying = true;
|
||
const ov = h(`<div class="overlay"><div class="spinner"></div><div class="msg">${esc(label)}</div><div class="msg" style="font-size:12px;color:var(--faint)">${T('MrTerm reconnects to confirm the change. Without confirmation the server restores the previous configuration after 90 seconds.')}</div></div>`);
|
||
this.el.append(ov);
|
||
try {
|
||
const r = await fn();
|
||
ov.remove();
|
||
if (r?.confirmed) { toast(r.direct ? T('Saved') : T('Network change applied and confirmed'), 'ok'); await this.refresh(); }
|
||
else {
|
||
setTabState(this, 'err');
|
||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--orange);max-width:560px;text-align:center">${T('MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.')}</div></div>`;
|
||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Reconnect')}</button>`);
|
||
b.onclick = () => { closeTab(this.id); openNetwork(this.host); };
|
||
$('.pane-empty', this.body).append(b);
|
||
}
|
||
} catch (e) { ov.remove(); toast(e.message, 'error'); }
|
||
this.applying = false;
|
||
}
|
||
|
||
async confirmApply(what) {
|
||
const r = await modal({ title: T('Apply network change?'), text: what,
|
||
body: `<p style="margin-top:0">${T('The change is applied immediately. If MrTerm cannot reconnect within 90 seconds, the server restores the previous configuration automatically.')}</p>
|
||
<div class="field"><label>${T('Reconnect via')}</label><input name="verify" value="${esc(this.host.address)}"/><div class="hint">${T('Change this if the change affects the address MrTerm uses to connect.')}</div></div>`,
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Apply'), value: 'form', cls: 'primary' }] });
|
||
return r ? (r.verify.trim() === this.host.address ? '' : r.verify.trim()) : null;
|
||
}
|
||
|
||
edit(name, kind) {
|
||
const d = this.data;
|
||
const isNew = !name;
|
||
const m = structuredClone(this.cfg(name) || { name: name || '', kind, method4: 'none', addresses: [], gateway: '', dns: [], search: [], mtu: '', method6: 'auto', addresses6: [], gateway6: '',
|
||
bond: kind === 'bond' ? { members: [], mode: '802.3ad', lacpRate: 'fast', hashPolicy: 'layer3+4', miimon: 100 } : undefined,
|
||
bridge: kind === 'bridge' ? { members: [], stp: false } : undefined, vlan: kind === 'vlan' ? { id: '', link: '' } : undefined });
|
||
if (!this.cfg(name) && name) { const live = d.interfaces.find((i) => i.name === name); if (live?.addrs.some((a) => a.dynamic)) m.method4 = 'dhcp'; }
|
||
const phys = [...new Set([...d.interfaces.filter((i) => !VIRTUAL_IF.test(i.name) && (i.kind === 'ethernet' || i.kind === 'bond' || i.kind === 'vlan')).map((i) => i.name), ...d.config.filter((c) => c.kind !== 'bridge').map((c) => c.name)])].filter((x) => x !== m.name).sort();
|
||
const memberBox = (sel, filter) => {
|
||
const box = h('<div class="vpn-hosts"></div>');
|
||
phys.filter(filter).forEach((x) => box.append(h(`<label class="check"><input type="checkbox" data-m="${esc(x)}" ${sel.includes(x) ? 'checked' : ''}/>${esc(x)}<span class="muted" style="margin-left:6px;font-size:11px">${esc(d.interfaces.find((i) => i.name === x)?.mac || '')}</span></label>`)));
|
||
if (!box.children.length) box.append(h(`<div class="hint">${T('No suitable interfaces found.')}</div>`));
|
||
return box;
|
||
};
|
||
const fields = [];
|
||
if (isNew) fields.push(field(T('Name'), 'name', kind === 'bond' ? 'bond0' : kind === 'bridge' ? (d.backend === 'ifupdown' ? 'vmbr1' : 'br0') : '', { placeholder: kind === 'vlan' ? 'eno1.100' : '', hint: kind === 'vlan' ? T('Usually <parent>.<VLAN ID>, e.g. eno1.100') : '' }));
|
||
let members = null;
|
||
if (m.kind === 'bond') {
|
||
members = memberBox(m.bond.members, (x) => this.kindOf({ name: x, kind: d.interfaces.find((i) => i.name === x)?.kind }) === 'ethernet');
|
||
fields.push(heading(T('Bond')), h(`<label class="flabel">${T('Members')}</label>`), members,
|
||
field(T('Mode'), 'bmode', m.bond.mode, { type: 'select', options: BOND_MODES }),
|
||
row(field(T('LACP rate'), 'lacp', m.bond.lacpRate || 'slow', { type: 'select', options: [['fast', 'fast (1 s)'], ['slow', 'slow (30 s)']] }),
|
||
field(T('Hash policy'), 'hash', m.bond.hashPolicy || 'layer2', { type: 'select', options: ['layer2', 'layer2+3', 'layer3+4', 'encap2+3', 'encap3+4'].map((x) => [x, x]) })),
|
||
field(T('MII monitoring (ms)'), 'miimon', m.bond.miimon ?? 100, { type: 'number' }));
|
||
}
|
||
if (m.kind === 'bridge') {
|
||
members = memberBox(m.bridge.members, () => true);
|
||
fields.push(heading(T('Bridge')), h(`<label class="flabel">${T('Ports')}</label>`), members, check(T('Spanning Tree (STP)'), 'stp', m.bridge.stp));
|
||
}
|
||
if (m.kind === 'vlan') fields.push(heading('VLAN'), row(field(T('VLAN ID'), 'vid', m.vlan.id, { type: 'number', placeholder: '100' }), field(T('Parent interface'), 'vlink', m.vlan.link, { type: 'select', options: [['', '—'], ...phys.map((x) => [x, x])] })));
|
||
const v4 = h('<div></div>');
|
||
v4.append(field(T('IPv4 addresses (one per line, CIDR)'), 'addresses', m.addresses.join('\n'), { type: 'textarea', placeholder: '192.168.1.10/24' }), field(T('Gateway'), 'gateway', m.gateway, { placeholder: '192.168.1.1' }));
|
||
const v6 = h('<div></div>');
|
||
v6.append(field(T('IPv6 addresses (one per line, CIDR)'), 'addresses6', m.addresses6.join('\n'), { type: 'textarea', placeholder: '2001:db8::10/64' }), field(T('IPv6 gateway'), 'gateway6', m.gateway6));
|
||
fields.push(heading('IPv4'), field(T('Method'), 'method4', m.method4, { type: 'select', options: [['dhcp', 'DHCP'], ['static', T('Static')], ['none', T('No IPv4 address')]] }), v4,
|
||
heading('DNS'), row(field(T('DNS servers'), 'dns', m.dns.join(', '), { placeholder: '1.1.1.1, 9.9.9.9' }), field(T('Search domains'), 'search', m.search.join(' '), { placeholder: 'example.lan' })),
|
||
heading('IPv6'), field(T('Method'), 'method6', m.method6, { type: 'select', options: [['auto', T('Automatic (SLAAC)')], ['dhcp', 'DHCPv6'], ['static', T('Static')], ['none', T('Disabled')]] }), v6,
|
||
heading(T('Advanced')), field('MTU', 'mtu', m.mtu, { type: 'number', placeholder: '1500' }));
|
||
const form = openDrawer(isNew ? T('New {kind}', { kind: NET_KINDS[kind] }) : `${NET_KINDS[m.kind]} ${m.name}`, fields, async () => {
|
||
const f = formValues(form);
|
||
const out = { ...m, name: isNew ? f.name.trim() : m.name, method4: f.method4, addresses: f.method4 === 'static' ? splitList(f.addresses) : [], gateway: f.method4 === 'static' ? f.gateway.trim() : '',
|
||
dns: splitList(f.dns), search: splitList(f.search), mtu: f.mtu, method6: f.method6, addresses6: f.method6 === 'static' ? splitList(f.addresses6) : [], gateway6: f.method6 === 'static' ? f.gateway6.trim() : '' };
|
||
const sel = members ? $$('[data-m]', members).filter((x) => x.checked).map((x) => x.dataset.m) : [];
|
||
if (m.kind === 'bond') { if (!sel.length) throw new Error(T('Select at least one member.')); out.bond = { members: sel, mode: f.bmode, lacpRate: f.bmode === '802.3ad' ? f.lacp : '', hashPolicy: f.hash, miimon: f.miimon }; }
|
||
if (m.kind === 'bridge') out.bridge = { members: sel, stp: f.stp };
|
||
if (m.kind === 'vlan') { out.vlan = { id: f.vid, link: f.vlink }; if (!f.vlink) throw new Error(T('Select the parent interface.')); }
|
||
if (isNew && d.config.some((c) => c.name === out.name)) throw new Error(T('An interface with this name already exists.'));
|
||
const verify = await this.confirmApply(`${NET_KINDS[out.kind]} ${out.name}`);
|
||
if (verify === null) return false;
|
||
closeDrawer();
|
||
this.run(T('Applying network configuration …'), () => api.call('network:save', this.id, out, verify));
|
||
return false;
|
||
});
|
||
const sync = () => {
|
||
v4.style.display = $('[name=method4]', form).value === 'static' ? '' : 'none';
|
||
v6.style.display = $('[name=method6]', form).value === 'static' ? '' : 'none';
|
||
const bm = $('[name=bmode]', form);
|
||
if (bm) $('[name=lacp]', form).closest('.field').style.display = bm.value === '802.3ad' ? '' : 'none';
|
||
};
|
||
form.addEventListener('change', sync);
|
||
sync();
|
||
}
|
||
|
||
async remove(c) {
|
||
if (!(await confirmBox(T('Delete {kind} {name}?', { kind: NET_KINDS[c.kind], name: c.name }), T('The interface is removed from the configuration. Members keep their current settings.')))) return;
|
||
const verify = await this.confirmApply(`${T('Delete')} ${c.name}`);
|
||
if (verify === null) return;
|
||
this.run(T('Applying network configuration …'), () => api.call('network:remove', this.id, c, verify));
|
||
}
|
||
|
||
async hostname() {
|
||
const n = await promptBox(T('Change hostname'), T('Hostname'), this.data.hostname);
|
||
if (!n || n === this.data.hostname) return;
|
||
try { await call('network:hostname', this.id, n.trim()); toast(T('Hostname changed'), 'ok'); this.refresh(); } catch {}
|
||
}
|
||
|
||
async resolv() {
|
||
const r = await modal({ title: T('DNS servers'), body: `<div class="field"><label>${T('DNS servers')}</label><input name="s" value="${esc(this.data.dns.servers.join(', '))}"/></div><div class="field"><label>${T('Search domains')}</label><input name="d" value="${esc(this.data.dns.search.join(' '))}"/></div><div class="hint">${T('Written to /etc/resolv.conf.')}</div>`,
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||
if (!r) return;
|
||
try { await call('network:resolv', this.id, splitList(r.s), splitList(r.d)); toast(T('Saved'), 'ok'); this.refresh(); } catch {}
|
||
}
|
||
|
||
async files() {
|
||
const list = [...new Set([...this.data.files, '/etc/hosts', ...(this.data.dns.editable ? ['/etc/resolv.conf'] : [])])];
|
||
const f = await new Promise((res) => ctxMenu(innerWidth - 320, 90, list.map((p) => ({ label: p, icon: 'file', run: () => res(p) }))));
|
||
let content;
|
||
try { content = await call('network:readFile', this.id, f); } catch { return; }
|
||
const net = !/^\/etc\/(hosts|resolv\.conf)$/.test(f);
|
||
const form = openDrawer(f, [field(T('Content'), 'content', content, { type: 'textarea', hint: net ? T('Saving applies the file with automatic rollback.') : '' })], async () => {
|
||
const v = formValues(form).content;
|
||
if (!net) { this.run(T('Saving …'), () => api.call('network:writeFile', this.id, f, v, '')); return; }
|
||
const verify = await this.confirmApply(f);
|
||
if (verify === null) return false;
|
||
closeDrawer();
|
||
this.run(T('Applying network configuration …'), () => api.call('network:writeFile', this.id, f, v, verify));
|
||
return false;
|
||
});
|
||
const ta = $('textarea', form); ta.classList.add('mono'); ta.style.minHeight = '60vh';
|
||
}
|
||
|
||
dispose() { this.unsub(); api.call('network:close', this.id).catch(() => {}); }
|
||
}
|
||
function openNetwork(host) { return new NetworkSession(host); }
|
||
|
||
// ============================================================ Command Palette / Quick Connect
|
||
function openPalette() {
|
||
if ($('.palette')) return;
|
||
const bg = h(`<div class="modal-bg"><div class="modal palette"><div class="pin">${ICONS.search}<input placeholder="${T('Search hosts or enter user@host:port or rdp://host …')}"/></div><div class="res"></div></div></div>`);
|
||
const inp = $('input', bg), res = $('.res', bg);
|
||
let items = [], idx = 0;
|
||
const close = () => bg.remove();
|
||
const draw = () => {
|
||
const q = inp.value.trim().toLowerCase();
|
||
items = [];
|
||
const quick = parseQuick(inp.value);
|
||
if (q && quick) items.push({ grp: 'Quick Connect', icon: avatar({ ...quick, label: quick.address }), label: `${quick.protocol.toUpperCase()} → ${inp.value.trim()}`, run: () => connectHost(quick) });
|
||
const hosts = S.vault.hosts.filter((x) => !q || [x.label, x.address, x.username, ...(x.tags || [])].some((f) => String(f || '').toLowerCase().includes(q)));
|
||
const recent = S.vault.history.map((x) => x.hostId);
|
||
hosts.sort((a, b) => ((recent.indexOf(a.id) + 1 || 999) - (recent.indexOf(b.id) + 1 || 999)));
|
||
hosts.slice(0, 12).forEach((x) => {
|
||
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
|
||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
|
||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) });
|
||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.wall}</div>`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) });
|
||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent)">${ICONS.network}</div>`, label: `${T('Network')}: ${x.label || x.address}`, run: () => openNetwork(x) });
|
||
});
|
||
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
|
||
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
|
||
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: T('Actions'), icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.plus}</div>`, label: l, run }));
|
||
idx = Math.min(idx, Math.max(0, items.length - 1));
|
||
let last = '';
|
||
res.innerHTML = '';
|
||
items.forEach((it, i) => {
|
||
if (it.grp !== last) { res.append(h(`<div class="grp">${esc(it.grp)}</div>`)); last = it.grp; }
|
||
const el = h(`<div class="item ${i === idx ? 'active' : ''}">${it.icon}<div class="meta"><div>${esc(it.label)}</div>${it.sub ? `<div class="sub mono" style="color:var(--muted)">${esc(it.sub.split('\n')[0])}</div>` : ''}</div>${i === idx ? '<span class="k">↵</span>' : ''}</div>`);
|
||
el.onclick = () => { close(); it.run(); };
|
||
el.onmousemove = () => { if (idx !== i) { idx = i; draw(); } };
|
||
res.append(el);
|
||
});
|
||
$('.item.active', res)?.scrollIntoView({ block: 'nearest' });
|
||
};
|
||
inp.oninput = () => { idx = 0; draw(); };
|
||
inp.onkeydown = (e) => {
|
||
if (e.key === 'ArrowDown') { idx = Math.min(items.length - 1, idx + 1); draw(); e.preventDefault(); }
|
||
if (e.key === 'ArrowUp') { idx = Math.max(0, idx - 1); draw(); e.preventDefault(); }
|
||
if (e.key === 'Enter' && items[idx]) { close(); items[idx].run(); }
|
||
if (e.key === 'Escape') close();
|
||
};
|
||
bg.onmousedown = (e) => { if (e.target === bg) close(); };
|
||
$('#modalRoot').append(bg);
|
||
draw();
|
||
inp.focus();
|
||
}
|
||
|
||
// ============================================================ Globale Events
|
||
$('#newTabBtn').onclick = openPalette;
|
||
$('#winMin').onclick = api.win.min;
|
||
$('#winMax').onclick = api.win.max;
|
||
$('#winClose').onclick = api.win.close;
|
||
$('#titlebar').addEventListener('dblclick', (e) => { if (e.target.classList.contains('drag-fill') || e.target.id === 'titlebar') api.win.max(); });
|
||
if (api.platform === 'darwin') $('.win-ctrls').style.display = 'none';
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!e.ctrlKey) return;
|
||
const inTerm = !!e.target.closest?.('.xterm');
|
||
if ($('.lock-screen')) return;
|
||
if (e.shiftKey && e.code === 'KeyL') { e.preventDefault(); lockNow(); }
|
||
else if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); }
|
||
else if (e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); }
|
||
else if (e.code === 'Tab') {
|
||
e.preventDefault();
|
||
const ids = ['home', ...S.tabs.map((t) => t.id)];
|
||
const i = ids.indexOf(S.active);
|
||
activateTab(ids[(i + (e.shiftKey ? -1 : 1) + ids.length) % ids.length]);
|
||
} else if (/^Digit[1-9]$/.test(e.code)) {
|
||
e.preventDefault();
|
||
const ids = ['home', ...S.tabs.map((t) => t.id)];
|
||
const n = Number(e.code.slice(5));
|
||
activateTab(n === 9 ? ids[ids.length - 1] : ids[n - 1] || ids[ids.length - 1]);
|
||
}
|
||
});
|
||
|
||
api.on('secret:request', async (req) => {
|
||
const r = await modal({
|
||
title: req.title || T('Authentication'),
|
||
text: req.host,
|
||
body: `<div class="field"><label>${esc(req.prompt || T('Password'))}</label><input name="v" type="${req.echo ? 'text' : 'password'}" autocomplete="off"/></div>`,
|
||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Log in'), value: 'form', cls: 'primary' }],
|
||
});
|
||
api.replySecret(req.reqId, r ? r.v : null);
|
||
});
|
||
api.on('fw:event', () => reload());
|
||
api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch(() => ({})); if (S.view === 'vpns' && S.active === 'home') render(); });
|
||
api.on('toast', (m, t) => toast(m, t));
|
||
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
|
||
|
||
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
|
||
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
|