first commit
This commit is contained in:
commit
19dfc51360
85 files changed
+25504
No files matched your search
+931
@@ -0,0 +1,931 @@
|
||||
'use strict';
|
||||
(() => {
|
||||
// ------------------------------------------------------------------ basics
|
||||
const $ = (s, r = document) => r.querySelector(s);
|
||||
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
||||
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
|
||||
const store = {
|
||||
get(k, d) { try { return localStorage.getItem(k) ?? d; } catch { return d; } },
|
||||
set(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
|
||||
};
|
||||
let LANG = store.get('ap.lang', 'en');
|
||||
const T = (pl, en) => (LANG === 'pl' ? pl : en);
|
||||
|
||||
const S = { session: null, csrf: '', settings: null, timezones: [], timers: [], sse: null, page: 'overview', devices: [] };
|
||||
|
||||
function clearTimers() {
|
||||
S.timers.forEach((t) => clearInterval(t));
|
||||
S.timers = [];
|
||||
if (S.sse) { S.sse.close(); S.sse = null; }
|
||||
}
|
||||
function every(ms, fn) { fn(); S.timers.push(setInterval(fn, ms)); }
|
||||
|
||||
// ------------------------------------------------------------------ formatting
|
||||
const fmtNum = (n) => (n == null ? '-' : Number(n).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-US'));
|
||||
function fmtBytes(b) {
|
||||
b = Number(b || 0);
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
|
||||
return `${b.toFixed(b < 10 && i ? 1 : 0)} ${u[i]}`;
|
||||
}
|
||||
function fmtDur(s) {
|
||||
s = Math.max(0, Math.floor(s));
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (d) return `${d}d ${h}h`;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${s % 60}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
const fmtTs = (t) => (t ? new Date(t * 1000).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-');
|
||||
const fmtTime = (t) => (t ? new Date(t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-');
|
||||
function ago(t) {
|
||||
if (!t) return T('nigdy', 'never');
|
||||
const s = Math.floor(Date.now() / 1000) - t;
|
||||
if (s < 60) return T(`${s}s temu`, `${s}s ago`);
|
||||
return T(`${fmtDur(s)} temu`, `${fmtDur(s)} ago`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ api
|
||||
async function api(method, path, body) {
|
||||
const opt = { method, credentials: 'same-origin', headers: {} };
|
||||
if (method !== 'GET') opt.headers['X-CSRF-Token'] = S.csrf;
|
||||
if (body !== undefined) {
|
||||
opt.headers['Content-Type'] = 'application/json';
|
||||
opt.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(path, opt);
|
||||
if (r.status === 401 && !path.startsWith('/api/login') && !path.startsWith('/api/setup')) {
|
||||
showAuth();
|
||||
throw new Error(T('Sesja wygasła', 'Session expired'));
|
||||
}
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
const data = ct.includes('json') ? await r.json() : await r.text();
|
||||
if (!r.ok) throw new Error((data && data.error) || `HTTP ${r.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
let toastT;
|
||||
function toast(msg, error) {
|
||||
const t = $('#toast');
|
||||
t.textContent = msg;
|
||||
t.classList.toggle('error', !!error);
|
||||
t.hidden = false;
|
||||
clearTimeout(toastT);
|
||||
toastT = setTimeout(() => { t.hidden = true; }, error ? 6000 : 3000);
|
||||
}
|
||||
|
||||
function applyWidths(root) {
|
||||
$$('[data-w]', root).forEach((el) => { el.style.width = `${Math.max(0, Math.min(100, Number(el.dataset.w)))}%`; });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ charts (SVG)
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
function niceMax(v) {
|
||||
if (v <= 0) return 1;
|
||||
const p = 10 ** Math.floor(Math.log10(v));
|
||||
for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p;
|
||||
return 10 * p;
|
||||
}
|
||||
|
||||
/** Line/area chart. series: [{name, cls:'k1', values:[...] }], labels: [string] */
|
||||
function lineChart(el, { labels, series, height = 170, area = true, fmt = fmtNum, bars = false, min = 1 }) {
|
||||
const W = Math.max(el.clientWidth || 600, 280), H = height, L = 46, R = 22, Tp = 8, B = 20;
|
||||
const n = labels.length;
|
||||
const max = niceMax(Math.max(min, ...series.flatMap((s) => s.values)));
|
||||
const slot = (W - L - R) / Math.max(1, n);
|
||||
const x = bars ? (i) => L + slot * (i + 0.5) : (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1));
|
||||
const y = (v) => Tp + (H - Tp - B) * (1 - v / max);
|
||||
let svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" width="100%" height="${H}" preserveAspectRatio="none">`;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const v = (max * g) / 4, yy = y(v);
|
||||
svg += `<line class="grid-line" x1="${L}" x2="${W - R}" y1="${yy}" y2="${yy}"/><text x="${L - 6}" y="${yy + 3}" text-anchor="end">${esc(fmt(Math.round(v * 100) / 100))}</text>`;
|
||||
}
|
||||
const step = Math.max(1, Math.ceil(n / 8));
|
||||
for (let i = 0; i < n; i += step) {
|
||||
const anchor = !bars && n > 1 && i === 0 ? 'start' : (x(i) > W - R - 30 ? 'end' : 'middle');
|
||||
svg += `<text x="${x(i)}" y="${H - 5}" text-anchor="${anchor}">${esc(labels[i])}</text>`;
|
||||
}
|
||||
if (bars) {
|
||||
const bw = Math.min(26, Math.max(1, (slot * 0.7) / series.length));
|
||||
series.forEach((s, si) => {
|
||||
s.values.forEach((v, i) => {
|
||||
const xx = x(i) - (bw * series.length) / 2 + si * bw;
|
||||
svg += `<rect class="br ${s.cls}" x="${xx}" y="${y(v)}" width="${bw}" height="${Math.max(0, y(0) - y(v))}" rx="1"/>`;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
series.forEach((s) => {
|
||||
const pts = s.values.map((v, i) => `${x(i)},${y(v)}`).join(' ');
|
||||
if (area && n > 1) svg += `<polygon class="ar ${s.cls}" points="${x(0)},${y(0)} ${pts} ${x(n - 1)},${y(0)}"/>`;
|
||||
svg += `<polyline class="ln ${s.cls}" points="${pts}"/>`;
|
||||
});
|
||||
}
|
||||
svg += `<line class="cursor" id="cur" x1="0" x2="0" y1="${Tp}" y2="${H - B}" visibility="hidden"/></svg>`;
|
||||
el.innerHTML = `<div class="chart-wrap">${svg}<div class="chart-tip" hidden></div></div>
|
||||
<div class="legend">${series.map((s) => `<span><i class="sw ${s.cls}"></i>${esc(s.name)}</span>`).join('')}</div>`;
|
||||
const wrap = $('.chart-wrap', el), tip = $('.chart-tip', el), cur = $('#cur', el), svgEl = $('svg', el);
|
||||
wrap.addEventListener('mousemove', (e) => {
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const px = ((e.clientX - rect.left) / rect.width) * W;
|
||||
const i = Math.max(0, Math.min(n - 1, bars ? Math.floor((px - L) / slot) : Math.round(((px - L) / (W - L - R)) * (n - 1))));
|
||||
cur.setAttribute('x1', x(i)); cur.setAttribute('x2', x(i)); cur.setAttribute('visibility', 'visible');
|
||||
tip.innerHTML = `<b>${esc(labels[i])}</b><br>${series.map((s) => `${esc(s.name)}: ${esc(fmt(s.values[i]))}`).join('<br>')}`;
|
||||
tip.hidden = false;
|
||||
const left = Math.min(e.clientX - rect.left + 12, rect.width - tip.offsetWidth - 4);
|
||||
tip.style.left = `${Math.max(0, left)}px`;
|
||||
tip.style.top = '6px';
|
||||
});
|
||||
wrap.addEventListener('mouseleave', () => { tip.hidden = true; cur.setAttribute('visibility', 'hidden'); });
|
||||
}
|
||||
|
||||
function spark(values, cls = 'k1') {
|
||||
const n = values.length;
|
||||
if (n < 2) return '';
|
||||
const max = Math.max(1, ...values);
|
||||
const pts = values.map((v, i) => `${(i * 100) / (n - 1)},${30 - (v / max) * 28}`).join(' ');
|
||||
return `<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none"><polygon class="ar ${cls}" points="0,32 ${pts} 100,32"/><polyline class="ln ${cls}" points="${pts}"/></svg>`;
|
||||
}
|
||||
|
||||
function donut(el, items, center) {
|
||||
const total = items.reduce((a, b) => a + b.value, 0) || 1;
|
||||
const r = 48, C = 2 * Math.PI * r;
|
||||
let off = 0, arcs = '';
|
||||
items.forEach((it, i) => {
|
||||
const len = (it.value / total) * C;
|
||||
arcs += `<circle class="dn k${(i % 7) + 1}" r="${r}" cx="60" cy="60" stroke-dasharray="${len} ${C - len}" stroke-dashoffset="${-off}" transform="rotate(-90 60 60)"><title>${esc(it.label)}: ${it.value}</title></circle>`;
|
||||
off += len;
|
||||
});
|
||||
el.innerHTML = `<div class="donut-layout"><svg viewBox="0 0 120 120" width="130" height="130">${arcs}<text class="donut-center" x="60" y="65" text-anchor="middle">${esc(center ?? total)}</text></svg>
|
||||
<div>${items.map((it, i) => `<div class="legend"><span><i class="sw k${(i % 7) + 1}"></i>${esc(it.label)} - <b>${fmtNum(it.value)}</b></span></div>`).join('') || `<div class="empty">${T('Brak danych', 'No data')}</div>`}</div></div>`;
|
||||
}
|
||||
|
||||
function hbars(items, cls = 'k1') {
|
||||
const max = Math.max(1, ...items.map((i) => i.value));
|
||||
return items.map((i) => `<div class="hbar"><span title="${esc(i.label)}">${esc(i.label)}</span><div class="track"><div class="fill ${cls}" data-w="${(i.value / max) * 100}"></div></div><span class="n">${fmtNum(i.value)}</span></div>`).join('')
|
||||
|| `<div class="empty">${T('Brak danych', 'No data')}</div>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ modal
|
||||
function modal(html, cls = '') {
|
||||
const root = $('#modal-root');
|
||||
root.innerHTML = `<div class="backdrop"><div class="dialog ${cls}" role="dialog" aria-modal="true">${html}</div></div>`;
|
||||
const bd = $('.backdrop', root);
|
||||
bd.addEventListener('mousedown', (e) => { if (e.target === bd) closeModal(); });
|
||||
$$('[data-close]', root).forEach((b) => b.addEventListener('click', closeModal));
|
||||
document.addEventListener('keydown', escClose);
|
||||
applyWidths(root);
|
||||
return $('.dialog', root);
|
||||
}
|
||||
function escClose(e) { if (e.key === 'Escape') closeModal(); }
|
||||
function closeModal() { $('#modal-root').innerHTML = ''; document.removeEventListener('keydown', escClose); }
|
||||
|
||||
function confirmBox(title, text, danger, typed) {
|
||||
return new Promise((resolve) => {
|
||||
const d = modal(`<div class="dialog-head"><h2>${esc(title)}</h2><button class="quiet" data-close>✕</button></div>
|
||||
<p class="fine">${esc(text)}</p>
|
||||
${typed ? `<label>${T('Wpisz', 'Type')} <b class="mono">${esc(typed)}</b> ${T('aby potwierdzić', 'to confirm')}</label><input id="cf-typed" autocomplete="off">` : ''}
|
||||
<div class="form-actions"><button class="quiet" data-close>${T('Anuluj', 'Cancel')}</button><button class="${danger ? 'danger' : 'primary'}" id="cf-ok">${T('Potwierdź', 'Confirm')}</button></div>`, 'small');
|
||||
$('#cf-ok', d).addEventListener('click', () => {
|
||||
if (typed && $('#cf-typed', d).value.trim() !== typed) { toast(T('Wpisany tekst się nie zgadza', 'Text does not match'), true); return; }
|
||||
closeModal(); resolve(true);
|
||||
});
|
||||
$$('[data-close]', d).forEach((b) => b.addEventListener('click', () => resolve(false)));
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ navigation
|
||||
const NAV = [
|
||||
['overview', T('Przegląd', 'Overview')],
|
||||
['live', T('Na żywo', 'Live')],
|
||||
['devices', T('Urządzenia', 'Devices')],
|
||||
['users', T('Skrzynki', 'Mailboxes')],
|
||||
['backends', T('Backendy', 'Backends')],
|
||||
['activesync', 'ActiveSync'],
|
||||
['policies', T('Polityki', 'Policies')],
|
||||
['security', T('Bezpieczeństwo', 'Security')],
|
||||
['logs', T('Logi i audyt', 'Logs & audit')],
|
||||
['about', T('O programie', 'About')],
|
||||
];
|
||||
const navLabel = (id) => (NAV.find((n) => n[0] === id) || NAV[0])[1];
|
||||
|
||||
function renderNav() {
|
||||
$('#nav').innerHTML = NAV.map((n, i) => `<button class="nav${S.page === n[0] ? ' active' : ''}" data-page="${n[0]}"><span>${String(i + 1).padStart(2, '0')}</span><b>${esc(n[1])}</b>${n[0] === 'devices' ? '<i class="count" id="nav-dev-count"></i>' : ''}</button>`).join('');
|
||||
$$('#nav .nav').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.page; $('#nav').classList.remove('open'); }));
|
||||
}
|
||||
|
||||
function go() {
|
||||
const id = (location.hash || '#overview').slice(1).split('/')[0];
|
||||
S.page = PAGES[id] ? id : 'overview';
|
||||
clearTimers();
|
||||
closeModal();
|
||||
renderNav();
|
||||
$('#page-label').textContent = navLabel(S.page);
|
||||
const v = $('#view');
|
||||
v.innerHTML = '';
|
||||
PAGES[S.page](v).catch((e) => { v.innerHTML = `<div class="notice">${esc(e.message)}</div>`; });
|
||||
}
|
||||
|
||||
function head(eyebrow, title, desc, actions = '') {
|
||||
return `<div class="page-head"><div><div class="small-label eyebrow">${esc(eyebrow)}</div><h1>${esc(title)}</h1>${desc ? `<p class="fine">${esc(desc)}</p>` : ''}</div><div class="top-actions">${actions}</div></div>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ pages
|
||||
const PAGES = {};
|
||||
|
||||
PAGES.overview = async (v) => {
|
||||
v.innerHTML = head(T('PANEL STEROWANIA', 'CONTROL ROOM'), T('Przegląd', 'Overview'), T('Ruch ActiveSync, aktywne połączenia push, urządzenia i stan backendów.', 'ActiveSync traffic, push connections, devices and backend state.'), `<select id="range" class="quiet"><option value="60">60 min</option><option value="360">6 h</option><option value="1440">24 h</option></select>`) +
|
||||
`<div class="grid stats" id="tiles"></div>
|
||||
<div class="grid two">
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">${T('ŻĄDANIA / MIN', 'REQUESTS / MIN')}</span><h2>${T('Ruch', 'Traffic')}</h2></div></div><div id="ch-req"></div></div>
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">${T('ELEMENTY / MIN', 'ITEMS / MIN')}</span><h2>${T('Synchronizacja danych', 'Data synchronisation')}</h2></div></div><div id="ch-items"></div></div>
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">${T('TRANSFER / MIN', 'TRANSFER / MIN')}</span><h2>${T('Dane', 'Data')}</h2></div></div><div id="ch-bytes"></div></div>
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">${T('OSTATNIE 7 DNI', 'LAST 7 DAYS')}</span><h2>${T('Żądania na godzinę', 'Requests per hour')}</h2></div></div><div id="ch-hourly"></div></div>
|
||||
</div>
|
||||
<div class="grid three">
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">${T('OD STARTU', 'SINCE START')}</span><h2>${T('Komendy', 'Commands')}</h2></div></div><div id="cmds"></div></div>
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">${T('URZĄDZENIA', 'DEVICES')}</span><h2>${T('Typy urządzeń', 'Device types')}</h2></div></div><div id="types"></div></div>
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">RUNTIME</span><h2>${T('Serwer', 'Server')}</h2></div></div><dl class="kv" id="runtime"></dl></div>
|
||||
</div>`;
|
||||
const rangeSel = $('#range', v);
|
||||
rangeSel.value = store.get('ap.range', '60');
|
||||
rangeSel.addEventListener('change', () => { store.set('ap.range', rangeSel.value); refresh(); });
|
||||
async function refresh() {
|
||||
const [o, ts] = await Promise.all([api('GET', '/api/overview'), api('GET', `/api/timeseries?minutes=${rangeSel.value}`)]);
|
||||
const lab = ts.map((b) => new Date(b.t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { hour: '2-digit', minute: '2-digit' }));
|
||||
const last = ts.slice(-15);
|
||||
const rpm = last.length ? Math.round(last.reduce((a, b) => a + b.requests, 0) / last.length * 10) / 10 : 0;
|
||||
const errRate = o.requests ? ((o.errors / o.requests) * 100).toFixed(2) : '0.00';
|
||||
const tile = (label, value, sub, cls = '', sp = '') => `<div class="card stat ${cls}"><span class="small-label">${esc(label)}</span><div class="value">${value}</div><div class="sub">${sub}</div>${sp}</div>`;
|
||||
$('#tiles').innerHTML = [
|
||||
tile(T('Żądania / min', 'Requests / min'), esc(rpm), T(`łącznie ${fmtNum(o.requests)}`, `total ${fmtNum(o.requests)}`), 'accent', spark(ts.map((b) => b.requests))),
|
||||
tile(T('Połączenia push', 'Push connections'), fmtNum(o.pings), T(`${o.active_requests} aktywnych żądań`, `${o.active_requests} active requests`), 'good', spark(ts.map((b) => b.pings), 'k2')),
|
||||
tile(T('Urządzenia', 'Devices'), `${fmtNum(o.devices_24h)}<small>/ ${fmtNum(o.devices_total)}</small>`, T(`aktywne 24h / wszystkie · ${fmtNum(o.partnerships_total)} partnerstw`, `active 24h / total · ${fmtNum(o.partnerships_total)} partnerships`)),
|
||||
tile(T('Skrzynki', 'Mailboxes'), `${fmtNum(o.users_active_15m)}<small>/ ${fmtNum(o.users_total)}</small>`, T('aktywne 15 min / wszystkie', 'active 15 min / total')),
|
||||
tile(T('Wysłane maile', 'Mails sent'), fmtNum(o.mails_sent), T(`elementy ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`, `items ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`), '', spark(ts.map((b) => b.mails_sent), 'k3')),
|
||||
tile(T('Błędy', 'Errors'), fmtNum(o.errors), `${errRate}% ${T('żądań', 'of requests')}`, o.errors ? 'bad' : '', spark(ts.map((b) => b.errors), 'k5')),
|
||||
tile(T('Nieudane logowania', 'Failed logins'), fmtNum(o.auth_failures), T(`${o.lockouts} blokad aktywnych`, `${o.lockouts} active lockouts`), o.lockouts ? 'bad' : '', spark(ts.map((b) => b.auth_failures), 'k5')),
|
||||
tile(T('Transfer', 'Transfer'), fmtBytes(o.bytes_out), `↑ ${fmtBytes(o.bytes_in)}`, '', spark(ts.map((b) => b.bytes_out), 'k4')),
|
||||
].join('');
|
||||
if (o.devices_pending || o.wipes_pending) {
|
||||
$('#tiles').insertAdjacentHTML('afterbegin', `<div class="notice">${o.devices_pending ? `<span><b>${o.devices_pending}</b> ${T('urządzeń czeka na zatwierdzenie', 'devices await approval')}</span>` : ''}${o.wipes_pending ? `<span><b>${o.wipes_pending}</b> ${T('zdalnych wymazań w toku', 'remote wipes pending')}</span>` : ''}</div>`);
|
||||
}
|
||||
lineChart($('#ch-req'), { labels: lab, series: [
|
||||
{ name: T('żądania', 'requests'), cls: 'k1', values: ts.map((b) => b.requests) },
|
||||
{ name: 'ping', cls: 'k2', values: ts.map((b) => b.pings) },
|
||||
{ name: T('błędy', 'errors'), cls: 'k5', values: ts.map((b) => b.errors) },
|
||||
] });
|
||||
lineChart($('#ch-items'), { labels: lab, bars: true, series: [
|
||||
{ name: T('do urządzeń', 'to devices'), cls: 'k3', values: ts.map((b) => b.items_out) },
|
||||
{ name: T('z urządzeń', 'from devices'), cls: 'k4', values: ts.map((b) => b.items_in) },
|
||||
] });
|
||||
lineChart($('#ch-bytes'), { labels: lab, fmt: fmtBytes, min: 1024, series: [
|
||||
{ name: T('wysłane', 'sent'), cls: 'k4', values: ts.map((b) => b.bytes_out) },
|
||||
{ name: T('odebrane', 'received'), cls: 'k6', values: ts.map((b) => b.bytes_in) },
|
||||
] });
|
||||
$('#cmds').innerHTML = hbars(o.commands.slice(0, 12).map(([k, s]) => ({ label: k, value: s.count })));
|
||||
applyWidths($('#cmds'));
|
||||
donut($('#types'), o.device_types.map(([t, n]) => ({ label: t, value: n })), o.devices_total);
|
||||
$('#runtime').innerHTML = [
|
||||
[T('Wersja', 'Version'), o.version],
|
||||
['Uptime', fmtDur(o.uptime)],
|
||||
[T('Pamięć RSS', 'Memory RSS'), fmtBytes(o.rss)],
|
||||
[T('Pula IMAP', 'IMAP pool'), o.imap_pool],
|
||||
[T('Baza danych', 'Database'), `${fmtBytes(o.db_bytes)} + WAL ${fmtBytes(o.wal_bytes)}`],
|
||||
['IMAP', o.backends.imap],
|
||||
['SMTP', o.backends.smtp],
|
||||
['CalDAV', o.backends.caldav || T('wyłączony', 'disabled')],
|
||||
['CardDAV', o.backends.carddav || T('wyłączony', 'disabled')],
|
||||
['Provisioning', o.provisioning ? T('włączony', 'enabled') : T('wyłączony', 'disabled')],
|
||||
['Autodiscover', fmtNum(o.autodiscover)],
|
||||
].map(([k, val]) => `<dt>${esc(k)}</dt><dd>${esc(val)}</dd>`).join('');
|
||||
$('#push-count').textContent = `PUSH ${o.pings}`;
|
||||
}
|
||||
async function hourly() {
|
||||
const h = await api('GET', '/api/stats/hourly?hours=168');
|
||||
const lab = h.map((b) => { const d = new Date(b.t * 1000); return `${d.toLocaleDateString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { weekday: 'short' })} ${String(d.getHours()).padStart(2, '0')}:00`; });
|
||||
lineChart($('#ch-hourly'), { labels: lab, bars: true, series: [
|
||||
{ name: T('żądania', 'requests'), cls: 'k1', values: h.map((b) => b.requests) },
|
||||
{ name: T('błędy', 'errors'), cls: 'k5', values: h.map((b) => b.errors) },
|
||||
] });
|
||||
}
|
||||
every(5000, () => refresh().catch(() => {}));
|
||||
every(300000, () => hourly().catch(() => {}));
|
||||
};
|
||||
|
||||
PAGES.live = async (v) => {
|
||||
v.innerHTML = head(T('NA ŻYWO', 'LIVE'), T('Ruch na żywo', 'Live traffic'), T('Trwające żądania, połączenia push (Ping / hanging Sync) i strumień zakończonych żądań.', 'In-flight requests, push connections (Ping / hanging Sync) and a stream of finished requests.'), `<button class="quiet" id="pause">${T('Pauza', 'Pause')}</button>`) +
|
||||
`<div class="grid two">
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">PUSH</span><h2>${T('Aktywne połączenia push', 'Active push connections')}</h2></div><span class="pill live" id="ping-n">0</span></div><div class="table-scroll" id="pings"></div></div>
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">IN-FLIGHT</span><h2>${T('Trwające żądania', 'Requests in progress')}</h2></div><span class="pill" id="act-n">0</span></div><div class="table-scroll" id="active"></div></div>
|
||||
</div>
|
||||
<div class="card"><div class="card-heading"><div><span class="small-label">STREAM</span><h2>${T('Zakończone żądania', 'Finished requests')}</h2></div>
|
||||
<input id="feed-filter" placeholder="${T('filtr: użytkownik, urządzenie, komenda', 'filter: user, device, command')}"></div>
|
||||
<div class="feed" id="feed"></div></div>`;
|
||||
let paused = false;
|
||||
$('#pause', v).addEventListener('click', (e) => { paused = !paused; e.target.textContent = paused ? T('Wznów', 'Resume') : T('Pauza', 'Pause'); });
|
||||
const filter = $('#feed-filter', v);
|
||||
const feed = $('#feed', v);
|
||||
const row = (e, fresh) => `<div class="feed-row${fresh ? ' fresh' : ''}" data-s="${esc(`${e.user} ${e.device_id} ${e.device_type} ${e.command} ${e.ip}`.toLowerCase())}">
|
||||
<span class="t">${esc(fmtTime(e.ts))}</span><span class="u" title="${esc(e.device_id)}">${esc(e.user)}</span>
|
||||
<span><span class="cmd">${esc(e.command)}</span> ${esc(e.device_type)} <span class="${e.http >= 400 || e.status.startsWith('error') ? 'err' : 'fine'}">${esc(e.http)} ${esc(e.status)}</span>${e.items_out || e.items_in ? ` <span class="fine">↓${e.items_out} ↑${e.items_in}</span>` : ''}</span>
|
||||
<span class="ms">${e.command === 'Ping' ? fmtDur(e.ms / 1000) : `${e.ms} ms`}</span><span class="fine">${fmtBytes(e.bytes_out)}</span></div>`;
|
||||
const applyFilter = () => {
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
$$('.feed-row', feed).forEach((r) => { r.hidden = q && !r.dataset.s.includes(q); });
|
||||
};
|
||||
filter.addEventListener('input', applyFilter);
|
||||
const data = await api('GET', '/api/live');
|
||||
feed.innerHTML = data.recent.map((e) => row(e, false)).join('') || `<div class="empty">${T('Brak żądań', 'No requests yet')}</div>`;
|
||||
S.sse = new EventSource('/api/events');
|
||||
S.sse.addEventListener('request', (m) => {
|
||||
if (paused) return;
|
||||
const e = JSON.parse(m.data);
|
||||
$('.empty', feed)?.remove();
|
||||
feed.insertAdjacentHTML('afterbegin', row(e, true));
|
||||
while (feed.children.length > 400) feed.lastElementChild.remove();
|
||||
applyFilter();
|
||||
});
|
||||
every(2000, async () => {
|
||||
const d = await api('GET', '/api/live').catch(() => null);
|
||||
if (!d) return;
|
||||
$('#ping-n').textContent = d.pings.length;
|
||||
$('#act-n').textContent = d.active.length;
|
||||
$('#pings').innerHTML = d.pings.length ? `<table><thead><tr><th>${T('Użytkownik', 'User')}</th><th>${T('Urządzenie', 'Device')}</th><th>${T('Typ', 'Kind')}</th><th class="num">${T('Foldery', 'Folders')}</th><th>${T('Heartbeat', 'Heartbeat')}</th></tr></thead><tbody>${d.pings.sort((a, b) => a.started - b.started).map((p) => {
|
||||
const el = d.now - p.started, pct = Math.min(100, (el / Math.max(1, p.lifetime)) * 100);
|
||||
return `<tr><td>${esc(p.user)}</td><td title="${esc(p.device_id)}">${esc(p.device_type)}<span class="sub">${esc(p.device_id)}</span></td><td>${esc(p.kind)}</td><td class="num">${p.folders}</td><td>${fmtDur(el)} / ${fmtDur(p.lifetime)}<div class="countdown"><div data-w="${pct}"></div></div></td></tr>`;
|
||||
}).join('')}</tbody></table>` : `<div class="empty">${T('Brak połączeń push', 'No push connections')}</div>`;
|
||||
$('#active').innerHTML = d.active.length ? `<table><thead><tr><th>${T('Komenda', 'Command')}</th><th>${T('Użytkownik', 'User')}</th><th>${T('Urządzenie', 'Device')}</th><th>IP</th><th class="num">${T('Czas', 'Time')}</th></tr></thead><tbody>${d.active.map((a) => `<tr><td>${esc(a.command)}</td><td>${esc(a.user)}</td><td>${esc(a.device_type)}<span class="sub">AS ${esc(a.version)}</span></td><td>${esc(a.ip)}</td><td class="num">${fmtDur(d.now - a.started)}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak', 'None')}</div>`;
|
||||
applyWidths(v);
|
||||
});
|
||||
};
|
||||
|
||||
function deviceState(d) {
|
||||
const p = [];
|
||||
if (d.pinging) p.push(`<span class="pill live">PUSH</span>`);
|
||||
if (d.blocked) p.push(`<span class="pill bad">${T('ZABLOKOWANE', 'BLOCKED')}</span>`);
|
||||
if (!d.approved) p.push(`<span class="pill">${T('DO ZATWIERDZENIA', 'PENDING')}</span>`);
|
||||
if (d.wipe_status === 2 || d.wipe_status === 4) p.push(`<span class="pill bad">WIPE ${d.wipe_status === 4 ? T('WYSŁANY', 'SENT') : T('CZEKA', 'PENDING')}</span>`);
|
||||
if (d.wipe_status === 8) p.push(`<span class="pill bad">${T('WYMAZANE', 'WIPED')}</span>`);
|
||||
if (!p.length) p.push(`<span class="pill ok">OK</span>`);
|
||||
return p.join(' ');
|
||||
}
|
||||
|
||||
PAGES.devices = async (v) => {
|
||||
v.innerHTML = head(T('PARTNERSTWA', 'PARTNERSHIPS'), T('Urządzenia', 'Devices'), T('Jeden wiersz to jedno fizyczne urządzenie; skrzynki skonfigurowane na nim są widoczne jako osobne partnerstwa. Kliknij skrzynkę, aby zobaczyć foldery, historię i akcje (wymazanie, resynchronizacja, blokada).', 'One row per physical device; each mailbox configured on it is a separate partnership. Click a mailbox for folders, history and actions (wipe, resync, block).')) +
|
||||
`<div class="card"><div class="toolbar"><input id="dev-q" placeholder="${T('Szukaj użytkownika, modelu, ID…', 'Search user, model, ID…')}">
|
||||
<select id="dev-f"><option value="">${T('Wszystkie', 'All')}</option><option value="push">PUSH</option><option value="pending">${T('Do zatwierdzenia', 'Pending')}</option><option value="blocked">${T('Zablokowane', 'Blocked')}</option><option value="stale">${T('Nieaktywne >30 dni', 'Inactive >30 days')}</option></select>
|
||||
<span class="fine" id="dev-n"></span></div><div class="table-scroll" id="dev-table"></div></div>`;
|
||||
const q = $('#dev-q', v), f = $('#dev-f', v);
|
||||
q.value = store.get('ap.devq', '');
|
||||
const draw = () => {
|
||||
store.set('ap.devq', q.value);
|
||||
const s = q.value.trim().toLowerCase(), now = Date.now() / 1000;
|
||||
const list = S.devices.filter((d) => {
|
||||
const hay = `${d.username} ${d.device_id} ${d.device_type} ${d.model} ${d.friendly_name} ${d.os} ${d.user_agent} ${d.last_ip}`.toLowerCase();
|
||||
if (s && !hay.includes(s)) return false;
|
||||
if (f.value === 'push') return d.pinging;
|
||||
if (f.value === 'pending') return !d.approved;
|
||||
if (f.value === 'blocked') return d.blocked;
|
||||
if (f.value === 'stale') return now - d.last_seen > 30 * 86400;
|
||||
return true;
|
||||
});
|
||||
// one row per physical device (DeviceId); every mailbox on it is a partnership chip
|
||||
const groups = new Map();
|
||||
list.forEach((d) => {
|
||||
if (!groups.has(d.device_id)) groups.set(d.device_id, []);
|
||||
groups.get(d.device_id).push(d);
|
||||
});
|
||||
const rows = Array.from(groups.values()).map((g) => g.sort((a, b2) => b2.last_seen - a.last_seen))
|
||||
.sort((a, b2) => b2[0].last_seen - a[0].last_seen);
|
||||
const physical = new Set(S.devices.map((d) => d.device_id)).size;
|
||||
$('#dev-n').textContent = T(`${rows.length} / ${physical} urządzeń · ${list.length} partnerstw`, `${rows.length} / ${physical} devices · ${list.length} partnerships`);
|
||||
$('#dev-table').innerHTML = rows.length ? `<table><thead><tr><th>${T('Urządzenie', 'Device')}</th><th>${T('Skrzynki', 'Mailboxes')}</th><th>OS</th><th>AS</th><th>${T('Ostatnio', 'Last seen')}</th><th>IP</th><th class="num">${T('Żądania', 'Requests')}</th><th>${T('Stan', 'State')}</th></tr></thead><tbody>${rows.map((g) => {
|
||||
const d = g[0];
|
||||
const merged = { ...d, pinging: g.some((x) => x.pinging), blocked: g.some((x) => x.blocked), approved: g.every((x) => x.approved), wipe_status: Math.max(...g.map((x) => x.wipe_status)) };
|
||||
return `<tr class="clickable" data-id="${d.id}">
|
||||
<td>${esc(d.friendly_name || d.model || d.device_type)}<span class="sub">${esc(d.device_type)} · ${esc(d.device_id)}</span></td>
|
||||
<td class="wrap">${g.map((x) => `<button class="chip" data-pid="${x.id}" title="${esc(ago(x.last_seen))}">${x.pinging ? '<span class="signal"></span>' : ''}${esc(x.username)}</button>`).join('')}</td>
|
||||
<td>${esc(d.os || '-')}</td><td>${esc(d.as_version || '-')}</td><td title="${esc(fmtTs(d.last_seen))}">${esc(ago(d.last_seen))}<span class="sub">${esc(d.last_command)}</span></td>
|
||||
<td>${esc(d.last_ip)}</td><td class="num">${fmtNum(g.reduce((a, x) => a + x.requests, 0))}</td><td>${deviceState(merged)}</td></tr>`;
|
||||
}).join('')}</tbody></table>` : `<div class="empty">${T('Brak urządzeń', 'No devices')}</div>`;
|
||||
$$('.chip[data-pid]', v).forEach((c) => c.addEventListener('click', (e) => { e.stopPropagation(); deviceDialog(Number(c.dataset.pid), load); }));
|
||||
$$('tr.clickable', v).forEach((tr) => tr.addEventListener('click', () => deviceDialog(Number(tr.dataset.id), load)));
|
||||
};
|
||||
async function load() {
|
||||
S.devices = await api('GET', '/api/devices');
|
||||
const nc = $('#nav-dev-count');
|
||||
if (nc) nc.textContent = new Set(S.devices.map((d) => d.device_id)).size;
|
||||
draw();
|
||||
}
|
||||
q.addEventListener('input', draw);
|
||||
f.addEventListener('change', draw);
|
||||
every(10000, () => load().catch(() => {}));
|
||||
};
|
||||
|
||||
async function deviceDialog(id, reload) {
|
||||
const d = await api('GET', `/api/devices/${id}`);
|
||||
const kv = [
|
||||
[T('Użytkownik', 'User'), d.username], ['Device ID', d.device_id], [T('Typ', 'Type'), d.device_type], [T('Model', 'Model'), d.model],
|
||||
[T('Nazwa', 'Friendly name'), d.friendly_name], ['OS', `${d.os} ${d.os_language}`], ['IMEI', d.imei], [T('Telefon', 'Phone'), d.phone_number],
|
||||
[T('Operator', 'Operator'), d.mobile_operator], ['User-Agent', d.user_agent], ['ActiveSync', d.as_version],
|
||||
[T('Polityka', 'Policy'), `${d.policy_name || '-'} (key ${d.policy_key || 0})`], [T('Pierwszy raz', 'First seen'), fmtTs(d.first_seen)],
|
||||
[T('Ostatnio', 'Last seen'), `${fmtTs(d.last_seen)} · ${d.last_command} · ${d.last_ip}`], [T('Żądania', 'Requests'), fmtNum(d.requests)],
|
||||
[T('Ping', 'Ping'), d.ping_lifetime ? `${d.ping_lifetime}s` : '-'],
|
||||
['Wipe', d.wipe_requested_at ? `${T('zlecony', 'requested')} ${fmtTs(d.wipe_requested_at)} ${T('przez', 'by')} ${d.wipe_requested_by || ''}${d.wipe_completed_at ? ` · ${T('potwierdzony', 'acknowledged')} ${fmtTs(d.wipe_completed_at)}` : ''}` : '-'],
|
||||
].filter(([, val]) => val && String(val).trim());
|
||||
const folders = d.folders.length ? `<table><thead><tr><th>${T('Folder', 'Folder')}</th><th>${T('Klasa', 'Class')}</th><th class="num">${T('Elementy', 'Items')}</th><th class="num">↓</th><th class="num">↑</th><th>${T('Ostatnia sync.', 'Last sync')}</th><th class="num">${T('Okno', 'Window')}</th><th class="num">${T('Pętle', 'Loops')}</th></tr></thead><tbody>${d.folders.map((f) => `<tr><td>${esc(f.folder_name)}<span class="sub">id ${esc(f.collection)}</span></td><td>${esc(f.folder_class)}</td><td class="num">${fmtNum(f.item_count)}</td><td class="num">${fmtNum(f.items_out)}</td><td class="num">${fmtNum(f.items_in)}</td><td>${esc(ago(f.last_sync))}</td><td class="num">${f.window_size}</td><td class="num">${f.loop_hits ? `<span class="pill bad">${f.loop_hits}</span>` : 0}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak zsynchronizowanych folderów', 'No synchronised folders')}</div>`;
|
||||
const recent = d.recent.length ? `<div class="feed">${d.recent.map((e) => `<div class="feed-row"><span class="t">${esc(fmtTime(e.ts))}</span><span class="u">${esc(e.ip)}</span><span><span class="cmd">${esc(e.command)}</span> <span class="${e.http >= 400 ? 'err' : 'fine'}">${e.http} ${esc(e.status)}</span></span><span class="ms">${e.ms} ms</span><span class="fine">${fmtBytes(e.bytes_out)}</span></div>`).join('')}</div>` : `<div class="empty">-</div>`;
|
||||
const btn = (act, label, cls = 'outline') => `<button class="${cls}" data-act="${act}">${esc(label)}</button>`;
|
||||
const dlg = modal(`<div class="dialog-head"><div><span class="small-label">${T('URZĄDZENIE', 'DEVICE')} #${d.id}</span><h2>${esc(d.friendly_name || d.model || d.device_type)} - ${esc(d.username)}</h2><div>${deviceState(d)}</div></div><button class="quiet" data-close>✕</button></div>
|
||||
<div class="actions-row">
|
||||
${d.approved ? '' : btn('approve', T('Zatwierdź', 'Approve'), 'primary')}
|
||||
${btn('resync', T('Resynchronizuj wszystko', 'Full resync'))}
|
||||
${btn('reprovision', T('Wymuś provisioning', 'Force re-provision'))}
|
||||
${d.blocked ? btn('unblock', T('Odblokuj', 'Unblock')) : btn('block', T('Zablokuj', 'Block'), 'danger')}
|
||||
${d.wipe_status === 2 || d.wipe_status === 4 ? btn('cancel-wipe', T('Anuluj wymazanie', 'Cancel wipe')) : btn('wipe', T('Zdalnie wymaż urządzenie', 'Remote wipe device'), 'danger')}
|
||||
${d.has_recovery_password ? btn('recovery-password', T('Hasło odzyskiwania', 'Recovery password'), 'quiet') : ''}
|
||||
${btn('remove', T('Usuń partnerstwo', 'Remove partnership'), 'danger')}
|
||||
</div>
|
||||
<div class="grid two"><div><h3>${T('Szczegóły', 'Details')}</h3><dl class="kv">${kv.map(([k, val]) => `<dt>${esc(k)}</dt><dd>${esc(val)}</dd>`).join('')}</dl>
|
||||
<h3 class="mt">${T('Notatki', 'Notes')}</h3><textarea id="dev-notes" maxlength="2000">${esc(d.notes)}</textarea><div class="form-actions"><button class="quiet" data-act="notes">${T('Zapisz notatkę', 'Save note')}</button></div></div>
|
||||
<div><h3>${T('Ostatnie żądania', 'Recent requests')}</h3>${recent}</div></div>
|
||||
<h3>${T('Foldery', 'Folders')}</h3><div class="table-scroll">${folders}</div>`);
|
||||
$$('[data-act]', dlg).forEach((b) => b.addEventListener('click', async () => {
|
||||
const act = b.dataset.act;
|
||||
if (act === 'wipe' && !(await confirmBox(T('Zdalne wymazanie', 'Remote wipe'), T('Urządzenie przy następnym połączeniu wykona reset do ustawień fabrycznych i usunie WSZYSTKIE dane. Tej operacji nie można cofnąć po jej wykonaniu przez urządzenie.', 'On its next connection the device performs a factory reset and erases ALL data. This cannot be undone once the device executes it.'), true, d.device_id))) return;
|
||||
if (act === 'remove' && !(await confirmBox(T('Usunąć partnerstwo?', 'Remove partnership?'), T('Stany synchronizacji zostaną usunięte; urządzenie zsynchronizuje się od nowa przy następnym połączeniu.', 'Sync states are removed; the device will resync from scratch on its next connection.'), true))) return;
|
||||
if (act === 'block' && !(await confirmBox(T('Zablokować urządzenie?', 'Block device?'), T('Urządzenie otrzyma HTTP 403 przy każdej próbie synchronizacji.', 'The device receives HTTP 403 on every sync attempt.'), true))) return;
|
||||
try {
|
||||
if (act === 'recovery-password') {
|
||||
const r = await api('POST', `/api/devices/${id}/recovery-password`);
|
||||
toast(`${T('Hasło odzyskiwania', 'Recovery password')}: ${r.password}`);
|
||||
return;
|
||||
}
|
||||
const body = act === 'notes' ? { notes: $('#dev-notes', dlg).value } : undefined;
|
||||
await api('POST', `/api/devices/${id}/${act}`, body);
|
||||
toast(T('Zapisano', 'Done'));
|
||||
if (act === 'remove') { closeModal(); } else if (act !== 'notes') { deviceDialog(id, reload); }
|
||||
reload && reload();
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}));
|
||||
}
|
||||
|
||||
PAGES.users = async (v) => {
|
||||
v.innerHTML = head(T('KONTA', 'ACCOUNTS'), T('Skrzynki', 'Mailboxes'), T('Skrzynki (loginy) synchronizowane przez ActiveSync. Jedna osoba może mieć kilka skrzynek na tym samym telefonie - kolumna "Na tym samym urządzeniu" je łączy. Blokada działa natychmiast na wszystkich urządzeniach.', 'Mailboxes (logins) syncing through ActiveSync. One person may have several mailboxes on the same phone - the "Same device as" column links them. Blocking applies immediately on every device.')) +
|
||||
`<div class="card"><div class="toolbar"><input id="u-q" placeholder="${T('Szukaj', 'Search')}"></div><div class="table-scroll" id="u-table"></div></div>
|
||||
<div class="card" id="lock-card"><div class="card-heading"><div><span class="small-label">${T('OCHRONA', 'PROTECTION')}</span><h2>${T('Aktywne blokady logowania', 'Active login lockouts')}</h2></div></div><div id="locks"></div></div>`;
|
||||
let users = [];
|
||||
const draw = () => {
|
||||
const s = $('#u-q', v).value.trim().toLowerCase();
|
||||
const list = users.filter((u) => !s || u.username.includes(s));
|
||||
$('#u-table').innerHTML = list.length ? `<table><thead><tr><th>${T('Skrzynka', 'Mailbox')}</th><th>${T('Urządzenia', 'Devices')}</th><th>${T('Na tym samym urządzeniu', 'Same device as')}</th><th class="num">PUSH</th><th class="num">${T('Żądania', 'Requests')}</th><th>${T('Ostatnio', 'Last seen')}</th><th>${T('Polityka', 'Policy')}</th><th>${T('Stan', 'State')}</th><th></th></tr></thead><tbody>${list.map((u) => `<tr><td>${esc(u.username)}</td>
|
||||
<td class="wrap">${esc((u.device_names || '-').split(',').join(', '))}<span class="sub">${u.devices} ${T('partnerstw', 'partnership(s)')}</span></td>
|
||||
<td class="wrap">${u.same_device ? u.same_device.split(',').map((x) => `<span class="chip">${esc(x)}</span>`).join('') : '<span class="fine">-</span>'}</td>
|
||||
<td class="num">${u.pings}</td><td class="num">${fmtNum(u.requests)}</td><td>${esc(ago(u.last_seen))}</td><td>${esc(u.policy)}</td><td>${u.blocked ? `<span class="pill bad">${T('ZABLOKOWANA', 'BLOCKED')}</span>` : '<span class="pill ok">OK</span>'}</td>
|
||||
<td><button class="${u.blocked ? 'outline' : 'danger'}" data-u="${esc(u.username)}" data-b="${u.blocked ? 0 : 1}">${u.blocked ? T('Odblokuj', 'Unblock') : T('Zablokuj', 'Block')}</button> <button class="quiet" data-dev="${esc(u.username)}">${T('Urządzenia', 'Devices')}</button></td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak skrzynek', 'No mailboxes')}</div>`;
|
||||
$$('[data-u]', v).forEach((b) => b.addEventListener('click', async () => {
|
||||
const name = b.dataset.u, block = b.dataset.b === '1';
|
||||
if (block && !(await confirmBox(T('Zablokować skrzynkę?', 'Block mailbox?'), name, true))) return;
|
||||
const s2 = await loadSettings();
|
||||
const set = new Set(s2.security.blocked_users.map((x) => x.toLowerCase()));
|
||||
if (block) set.add(name.toLowerCase()); else set.delete(name.toLowerCase());
|
||||
s2.security.blocked_users = Array.from(set);
|
||||
await saveSettings(s2);
|
||||
load();
|
||||
}));
|
||||
$$('[data-dev]', v).forEach((b) => b.addEventListener('click', () => { store.set('ap.devq', b.dataset.dev); location.hash = 'devices'; }));
|
||||
};
|
||||
async function load() {
|
||||
users = await api('GET', '/api/users');
|
||||
draw();
|
||||
const locks = await api('GET', '/api/lockouts');
|
||||
$('#locks').innerHTML = locks.length ? `<table><thead><tr><th>${T('Klucz', 'Key')}</th><th>${T('Do', 'Until')}</th><th></th></tr></thead><tbody>${locks.map((l) => `<tr><td>${esc(l.key)}</td><td>${esc(fmtTs(l.until))}</td><td><button class="outline" data-unlock="${esc(l.key)}">${T('Odblokuj', 'Unlock')}</button></td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak blokad', 'No lockouts')}</div>`;
|
||||
$$('[data-unlock]', v).forEach((b) => b.addEventListener('click', async () => { await api('POST', '/api/lockouts/unlock', { key: b.dataset.unlock }); load(); }));
|
||||
}
|
||||
$('#u-q', v).addEventListener('input', draw);
|
||||
every(15000, () => load().catch(() => {}));
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ settings forms
|
||||
async function loadSettings() {
|
||||
const r = await api('GET', '/api/settings');
|
||||
S.settings = r.settings;
|
||||
S.timezones = r.timezones;
|
||||
return JSON.parse(JSON.stringify(r.settings));
|
||||
}
|
||||
async function saveSettings(s) {
|
||||
const r = await api('PUT', '/api/settings', s);
|
||||
toast(T(`Zapisano i zastosowano (rewizja ${r.revision})`, `Saved and applied (revision ${r.revision})`));
|
||||
return loadSettings();
|
||||
}
|
||||
const getP = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
|
||||
function setP(o, p, val) {
|
||||
const ks = p.split('.');
|
||||
let c = o;
|
||||
ks.slice(0, -1).forEach((k) => { c = c[k]; });
|
||||
c[ks[ks.length - 1]] = val;
|
||||
}
|
||||
|
||||
/** field: [path, label, type, opts] type: text|number|bool|select|list|secret|tz|textarea */
|
||||
function fieldHtml(s, f) {
|
||||
const [path, label, type, opt = {}] = f;
|
||||
const val = getP(s, path);
|
||||
const id = `f-${path.replace(/\./g, '-')}`;
|
||||
const help = opt.help ? `<div class="help">${esc(opt.help)}</div>` : '';
|
||||
const wide = opt.wide || type === 'list' || type === 'textarea' ? ' wide' : '';
|
||||
if (type === 'bool') return `<div class="field${wide}"><label class="check"><input type="checkbox" id="${id}" data-path="${path}" data-type="bool" ${val ? 'checked' : ''}> ${esc(label)}</label>${help}</div>`;
|
||||
let input;
|
||||
if (type === 'select') input = `<select id="${id}" data-path="${path}" data-type="${opt.num ? 'number' : 'text'}">${opt.options.map(([k, l]) => `<option value="${esc(k)}" ${String(val) === String(k) ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
|
||||
else if (type === 'tz') input = `<input id="${id}" data-path="${path}" data-type="text" list="tz-list" value="${esc(val)}"><datalist id="tz-list">${S.timezones.map((t) => `<option value="${esc(t)}">`).join('')}</datalist>`;
|
||||
else if (type === 'list') input = `<textarea id="${id}" data-path="${path}" data-type="list" spellcheck="false" placeholder="${esc(opt.ph || T('jedna wartość w linii', 'one value per line'))}">${esc((val || []).join('\n'))}</textarea>`;
|
||||
else if (type === 'secret') input = `<input id="${id}" type="password" data-path="${path}" data-type="secret" autocomplete="new-password" placeholder="${val === '__SET__' ? T('•••••• (ustawione - zostaw puste, by nie zmieniać)', '•••••• (set - leave empty to keep)') : ''}">`;
|
||||
else if (type === 'number') input = `<input id="${id}" type="number" data-path="${path}" data-type="number" value="${esc(val)}" ${opt.min != null ? `min="${opt.min}"` : ''} ${opt.max != null ? `max="${opt.max}"` : ''}>`;
|
||||
else input = `<input id="${id}" data-path="${path}" data-type="text" value="${esc(val)}" spellcheck="false" ${opt.ph ? `placeholder="${esc(opt.ph)}"` : ''}>`;
|
||||
return `<div class="field${wide}"><label for="${id}">${esc(label)}</label>${input}${help}</div>`;
|
||||
}
|
||||
|
||||
function collect(root, s) {
|
||||
$$('[data-path]', root).forEach((el) => {
|
||||
const p = el.dataset.path, t = el.dataset.type;
|
||||
let val;
|
||||
if (t === 'bool') val = el.checked;
|
||||
else if (t === 'number') val = el.value === '' ? 0 : Number(el.value);
|
||||
else if (t === 'list') val = el.value.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||
else if (t === 'secret') val = el.value === '' ? (getP(S.settings, p) === '__SET__' ? '__SET__' : '') : el.value;
|
||||
else val = el.value.trim();
|
||||
setP(s, p, val);
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
function section(s, title, desc, fields, extra = '') {
|
||||
return `<div class="card"><div class="section-title"><div><h2>${esc(title)}</h2>${desc ? `<p class="fine">${esc(desc)}</p>` : ''}</div></div><div class="form-grid">${fields.map((f) => fieldHtml(s, f)).join('')}</div>${extra}</div>`;
|
||||
}
|
||||
|
||||
async function settingsPage(v, eyebrow, title, desc, build, after) {
|
||||
const s = await loadSettings();
|
||||
v.innerHTML = head(eyebrow, title, desc, `<span class="dirty-flag" id="dirty" hidden>● ${T('niezapisane zmiany', 'unsaved changes')}</span><button class="quiet" id="reset">${T('Cofnij', 'Revert')}</button><button class="primary" id="save">${T('Zapisz i zastosuj', 'Save & apply')}</button>`) + `<div class="stack" id="form">${build(s)}</div>`;
|
||||
const form = $('#form', v);
|
||||
form.addEventListener('input', () => { $('#dirty').hidden = false; });
|
||||
form.addEventListener('change', () => { $('#dirty').hidden = false; });
|
||||
$('#reset', v).addEventListener('click', () => go());
|
||||
$('#save', v).addEventListener('click', async (e) => {
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
const cur = JSON.parse(JSON.stringify(S.settings));
|
||||
collect(form, cur);
|
||||
if (after?.collect) after.collect(form, cur);
|
||||
await saveSettings(cur);
|
||||
go();
|
||||
} catch (err) { toast(err.message, true); } finally { e.target.disabled = false; }
|
||||
});
|
||||
after?.bind?.(form, s);
|
||||
}
|
||||
|
||||
function testBlock(kind) {
|
||||
return `<div class="test-row"><div><label>${T('Login testowy', 'Test login')}</label><input data-test-user="${kind}" autocomplete="off" placeholder="user@example.com"></div>
|
||||
<div><label>${T('Hasło testowe (nie jest zapisywane)', 'Test password (not stored)')}</label><input type="password" data-test-pass="${kind}" autocomplete="new-password"></div>
|
||||
<button class="outline" data-test="${kind}">${T('Testuj połączenie', 'Test connection')}</button></div><div class="result" data-result="${kind}" hidden></div>`;
|
||||
}
|
||||
function bindTests(form) {
|
||||
$$('[data-test]', form).forEach((b) => b.addEventListener('click', async () => {
|
||||
const k = b.dataset.test;
|
||||
const out = $(`[data-result="${k}"]`, form);
|
||||
out.hidden = false; out.className = 'result'; out.textContent = T('Testowanie…', 'Testing…');
|
||||
b.disabled = true;
|
||||
try {
|
||||
const cur = collect(form, JSON.parse(JSON.stringify(S.settings)));
|
||||
const r = await api('POST', `/api/test/${k}`, { login: $(`[data-test-user="${k}"]`, form).value, password: $(`[data-test-pass="${k}"]`, form).value, settings: cur });
|
||||
out.classList.add(r.ok ? 'ok' : 'bad');
|
||||
out.textContent = r.message;
|
||||
} catch (e) { out.classList.add('bad'); out.textContent = e.message; } finally { b.disabled = false; }
|
||||
}));
|
||||
}
|
||||
|
||||
const TLS = () => [['none', T('brak (tylko localhost)', 'none (localhost only)')], ['start_tls', 'STARTTLS'], ['tls', T('TLS (implicit)', 'TLS (implicit)')]];
|
||||
|
||||
PAGES.backends = (v) => settingsPage(v, T('ŹRÓDŁA DANYCH', 'DATA SOURCES'), T('Backendy', 'Backends'), T('Poczta przez IMAP/SMTP, kalendarze i zadania przez CalDAV, kontakty przez CardDAV - odpowiednik backendu "combined" z Z-Push.', 'Mail via IMAP/SMTP, calendars and tasks via CalDAV, contacts via CardDAV - the Z-Push "combined" backend.'), (s) =>
|
||||
section(s, 'IMAP', T('Serwer poczty przychodzącej (np. Dovecot). Uwierzytelnianie urządzeń odbywa się loginem IMAP.', 'Incoming mail server (e.g. Dovecot). Devices authenticate with their IMAP login.'), [
|
||||
['imap.host', T('Host', 'Host'), 'text'], ['imap.port', T('Port', 'Port'), 'number', { min: 1, max: 65535 }],
|
||||
['imap.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }], ['imap.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
|
||||
['imap.folder_prefix', T('Prefiks folderów', 'Folder prefix'), 'text', { ph: 'INBOX.' }],
|
||||
['imap.use_special_use', T('Rozpoznawaj foldery po SPECIAL-USE', 'Detect folders via SPECIAL-USE'), 'bool'],
|
||||
['imap.folder_inbox', 'Inbox', 'text'], ['imap.folder_sent', T('Wysłane', 'Sent'), 'text'], ['imap.folder_drafts', T('Szkice', 'Drafts'), 'text'],
|
||||
['imap.folder_trash', T('Kosz', 'Trash'), 'text'], ['imap.folder_spam', 'Spam', 'text'], ['imap.folder_archive', T('Archiwum', 'Archive'), 'text'],
|
||||
['imap.excluded_folders', T('Ukryte foldery', 'Hidden folders'), 'list', { ph: 'Shared/*\nPublic' }],
|
||||
['imap.autoseen_on_delete', T('Oznacz jako przeczytane przed usunięciem', 'Mark read before delete'), 'bool'],
|
||||
['imap.inline_forward', T('Przekazywanie inline (zamiast załącznika .eml)', 'Inline forward (instead of .eml)'), 'bool'],
|
||||
['imap.timeout_secs', T('Timeout (s)', 'Timeout (s)'), 'number', { min: 5 }], ['imap.pool_idle_secs', T('Czas życia połączeń w puli (s)', 'Pool idle time (s)'), 'number'],
|
||||
['imap.pool_max_per_user', T('Maks. połączeń w puli / użytkownika', 'Max pooled per user'), 'number', { min: 0, max: 20 }],
|
||||
], testBlock('imap')) +
|
||||
section(s, 'SMTP', T('Wysyłka poczty z urządzeń (SendMail / SmartReply / SmartForward).', 'Outgoing mail from devices (SendMail / SmartReply / SmartForward).'), [
|
||||
['smtp.method', T('Metoda', 'Method'), 'select', { options: [['smtp', 'SMTP'], ['sendmail', 'sendmail']] }],
|
||||
['smtp.host', 'Host', 'text'], ['smtp.port', 'Port', 'number'], ['smtp.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }],
|
||||
['smtp.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
|
||||
['smtp.auth', T('Uwierzytelnianie', 'Authentication'), 'select', { options: [['user_credentials', T('dane użytkownika urządzenia', 'device user credentials')], ['static', T('stałe konto relay', 'static relay account')], ['none', T('brak', 'none')]] }],
|
||||
['smtp.username', T('Login relay', 'Relay username'), 'text'], ['smtp.password', T('Hasło relay', 'Relay password'), 'secret'],
|
||||
['smtp.sendmail_path', T('Ścieżka sendmail', 'sendmail path'), 'text'], ['smtp.timeout_secs', 'Timeout (s)', 'number'],
|
||||
['smtp.max_message_mb', T('Maks. rozmiar wiadomości (MB)', 'Max message size (MB)'), 'number'],
|
||||
['smtp.save_in_sent', T('Zapisuj kopię w Wysłanych', 'Save copy to Sent'), 'bool'],
|
||||
['smtp.enforce_from', T('Wymuszaj adres nadawcy = adres konta', 'Force sender = account address'), 'bool', { help: T('Zapobiega podszywaniu się pod inne adresy.', 'Prevents sender spoofing.') }],
|
||||
], testBlock('smtp')) +
|
||||
section(s, 'CalDAV', T('Kalendarze i zadania (VEVENT/VTODO). Placeholders: %u login, %l część lokalna, %d domena.', 'Calendars and tasks (VEVENT/VTODO). Placeholders: %u login, %l local part, %d domain.'), [
|
||||
['caldav.enabled', T('Włączony', 'Enabled'), 'bool'], ['caldav.base_url', 'URL', 'text', { ph: 'https://dav.example.com' }],
|
||||
['caldav.path', T('Ścieżka kalendarzy', 'Calendar home path'), 'text', { ph: '/dav/calendars/%u/' }], ['caldav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
|
||||
['caldav.default_collection', T('Domyślny kalendarz', 'Default calendar'), 'text'], ['caldav.include_regex', T('Tylko kolekcje pasujące do regex', 'Include regex'), 'text'],
|
||||
['caldav.exclude_regex', T('Pomiń kolekcje pasujące do regex', 'Exclude regex'), 'text'], ['caldav.timeout_secs', 'Timeout (s)', 'number'],
|
||||
], testBlock('caldav')) +
|
||||
section(s, 'CardDAV', T('Kontakty oraz globalna książka adresowa (GAL) do wyszukiwania i rozwiązywania odbiorców.', 'Contacts plus a global address list (GAL) for search and recipient resolution.'), [
|
||||
['carddav.enabled', T('Włączony', 'Enabled'), 'bool'], ['carddav.base_url', 'URL', 'text'],
|
||||
['carddav.path', T('Ścieżka książek adresowych', 'Address book home path'), 'text', { ph: '/dav/addressbooks/%u/' }], ['carddav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
|
||||
['carddav.default_collection', T('Domyślna książka', 'Default book'), 'text'], ['carddav.gal_path', T('Ścieżka GAL', 'GAL path'), 'text', { ph: '/dav/addressbooks/%d/gal/' }],
|
||||
['carddav.gal_min_length', T('Min. długość zapytania GAL', 'GAL min query length'), 'number'], ['carddav.max_picture_kb', T('Maks. zdjęcie kontaktu (KB)', 'Max contact picture (KB)'), 'number'],
|
||||
['carddav.include_regex', 'Include regex', 'text'], ['carddav.exclude_regex', 'Exclude regex', 'text'], ['carddav.timeout_secs', 'Timeout (s)', 'number'],
|
||||
], testBlock('carddav')), { bind: bindTests });
|
||||
|
||||
PAGES.activesync = (v) => settingsPage(v, 'PROTOCOL', 'ActiveSync', T('Logowanie, protokół, synchronizacja, push i Autodiscover.', 'Login, protocol, synchronisation, push and Autodiscover.'), (s) =>
|
||||
section(s, T('Ogólne', 'General'), '', [
|
||||
['general.server_name', T('Nazwa serwera', 'Server name'), 'text'], ['general.default_timezone', T('Domyślna strefa czasowa', 'Default time zone'), 'tz'],
|
||||
['general.login_format', T('Format loginu', 'Login format'), 'select', { options: [['as_given', T('jak podany przez urządzenie', 'as given by device')], ['local_part', T('tylko część lokalna (bez @domena)', 'local part only')], ['full_email', T('pełny e-mail (dopisz domenę)', 'full e-mail (append domain)')]], help: T('Odpowiednik USE_FULLEMAIL_FOR_LOGIN. Prefiks DOMENA\\ jest zawsze usuwany.', 'Equivalent of USE_FULLEMAIL_FOR_LOGIN. DOMAIN\\ prefixes are always stripped.') }],
|
||||
['general.default_domain', T('Domyślna domena', 'Default domain'), 'text'],
|
||||
['general.email_template', T('Szablon adresu e-mail', 'E-mail address template'), 'text', { help: '{login} {local} {domain}' }],
|
||||
['general.max_protocol', T('Najwyższa wersja protokołu', 'Highest protocol version'), 'select', { options: ['14.1', '14.0', '12.1', '12.0', '2.5'].map((x) => [x, x]) }],
|
||||
['general.allowed_domains', T('Dozwolone domeny (puste = wszystkie)', 'Allowed domains (empty = all)'), 'list'],
|
||||
]) +
|
||||
section(s, T('Synchronizacja', 'Synchronisation'), '', [
|
||||
['sync.max_items', T('Maks. elementów w paczce (SYNC_MAX_ITEMS)', 'Max items per batch'), 'number', { min: 1, max: 2000 }],
|
||||
['sync.filter_time_max', T('Maks. zakres czasu', 'Max time window'), 'select', { num: true, options: [[0, T('bez limitu', 'unlimited')], [1, T('1 dzień', '1 day')], [2, T('3 dni', '3 days')], [3, T('1 tydzień', '1 week')], [4, T('2 tygodnie', '2 weeks')], [5, T('1 miesiąc', '1 month')], [6, T('3 miesiące', '3 months')], [7, T('6 miesięcy', '6 months')]] }],
|
||||
['sync.server_wins', T('Konflikty: wygrywa serwer', 'Conflicts: server wins'), 'bool'], ['sync.loop_detection', T('Wykrywanie pętli synchronizacji', 'Sync loop detection'), 'bool'],
|
||||
['sync.max_attachment_mb', T('Maks. załącznik (MB)', 'Max attachment (MB)'), 'number'], ['sync.max_request_mb', T('Maks. żądanie (MB)', 'Max request (MB)'), 'number'],
|
||||
['sync.heartbeat_min', T('Hanging Sync min (s)', 'Hanging Sync min (s)'), 'number'], ['sync.heartbeat_max', T('Hanging Sync max (s)', 'Hanging Sync max (s)'), 'number'],
|
||||
['sync.search_wait_secs', T('Limit czasu wyszukiwania (s)', 'Search time limit (s)'), 'number'], ['sync.search_max_results', T('Maks. wyników wyszukiwania', 'Max search results'), 'number'],
|
||||
['sync.retry_after_secs', 'Retry-After (s)', 'number'],
|
||||
]) +
|
||||
section(s, 'Push (Ping)', T('Urządzenia trzymają otwarte połączenie; serwer sprawdza zmiany w backendzie co zadany interwał.', 'Devices keep a connection open; the server checks the backend for changes every interval.'), [
|
||||
['ping.check_interval_secs', T('Interwał sprawdzania (s)', 'Check interval (s)'), 'number', { min: 5 }],
|
||||
['ping.lifetime_min', T('Min. heartbeat (s)', 'Min heartbeat (s)'), 'number'], ['ping.lifetime_max', T('Maks. heartbeat (s)', 'Max heartbeat (s)'), 'number'],
|
||||
['ping.max_folders', T('Maks. folderów', 'Max folders'), 'number'],
|
||||
]) +
|
||||
section(s, 'Autodiscover', T('Automatyczna konfiguracja kont na telefonach (/autodiscover/autodiscover.xml).', 'Automatic account configuration (/autodiscover/autodiscover.xml).'), [
|
||||
['autodiscover.enabled', T('Włączony', 'Enabled'), 'bool'], ['autodiscover.require_auth', T('Wymagaj poprawnego hasła', 'Require valid password'), 'bool'],
|
||||
['autodiscover.server_url', T('Publiczny URL ActiveSync', 'Public ActiveSync URL'), 'text', { wide: true, ph: 'https://mail.example.com/Microsoft-Server-ActiveSync', help: T('Puste = wyznaczony z nagłówka Host.', 'Empty = derived from the Host header.') }],
|
||||
]) +
|
||||
section(s, T('Logowanie i retencja', 'Logging & retention'), '', [
|
||||
['logging.log_wbxml', T('Loguj zdekodowany WBXML (debug, zawiera treść poczty!)', 'Log decoded WBXML (debug, contains mail data!)'), 'bool'],
|
||||
['logging.debug_users', T('Użytkownicy z logowaniem debug', 'Debug users'), 'list'],
|
||||
['logging.ring_size', T('Linii logu w panelu', 'Log lines in panel'), 'number'], ['logging.stats_retention_days', T('Retencja statystyk (dni)', 'Stats retention (days)'), 'number'],
|
||||
['logging.audit_retention_days', T('Retencja audytu (dni)', 'Audit retention (days)'), 'number'],
|
||||
['panel.device_inactive_days', T('Usuwaj urządzenia nieaktywne (dni, 0 = nigdy)', 'Remove inactive devices (days, 0 = never)'), 'number'],
|
||||
]));
|
||||
|
||||
const POLICY_FIELDS = () => [
|
||||
['devpwenabled', T('Wymagaj hasła urządzenia', 'Require device password'), 'bool'], ['alphanumpwreq', T('Hasło alfanumeryczne', 'Alphanumeric password'), 'bool'],
|
||||
['allowsimpledevpw', T('Pozwól na proste hasła', 'Allow simple passwords'), 'bool'], ['mindevpwlenngth', T('Min. długość hasła', 'Min password length'), 'number'],
|
||||
['mindevcomplexchars', T('Min. znaków złożonych', 'Min complex characters'), 'number'], ['maxinacttimedevlock', T('Blokada po bezczynności (s)', 'Lock after inactivity (s)'), 'number'],
|
||||
['maxdevpwfailedattempts', T('Wymaż po N błędnych hasłach', 'Wipe after N failed passwords'), 'number'], ['devpwexpiration', T('Wygaśnięcie hasła (dni)', 'Password expiration (days)'), 'number'],
|
||||
['devpwhistory', T('Historia haseł', 'Password history'), 'number'], ['pwrecoveryenabled', T('Odzyskiwanie hasła', 'Password recovery'), 'bool'],
|
||||
['devencenabled', T('Szyfrowanie urządzenia', 'Device encryption'), 'bool'], ['reqdevenc', T('Wymagaj szyfrowania', 'Require encryption'), 'bool'],
|
||||
['attenabled', T('Załączniki dozwolone', 'Attachments allowed'), 'bool'], ['maxattsize', T('Maks. załącznik (bajty, -1 = bez limitu)', 'Max attachment (bytes, -1 unlimited)'), 'number'],
|
||||
['allowhtmlemail', T('Poczta HTML', 'HTML email'), 'bool'], ['maxemailagefilter', T('Maks. wiek poczty (0-5)', 'Max email age filter'), 'number'],
|
||||
['maxcalagefilter', T('Maks. wiek kalendarza (0,4-7)', 'Max calendar age filter'), 'number'], ['maxemailbodytruncsize', T('Obcięcie treści (bajty, -1)', 'Body truncation (bytes, -1)'), 'number'],
|
||||
['maxemailhtmlbodytruncsize', T('Obcięcie HTML (bajty, -1)', 'HTML truncation (bytes, -1)'), 'number'], ['allowstoragecard', T('Karta pamięci', 'Storage card'), 'bool'],
|
||||
['allowcam', T('Aparat', 'Camera'), 'bool'], ['allowwifi', 'Wi-Fi', 'bool'], ['allowbluetooth', 'Bluetooth (0/1/2)', 'number'], ['allowirda', 'IrDA', 'bool'],
|
||||
['allowtextmessaging', 'SMS', 'bool'], ['allowpopimapemail', 'POP/IMAP', 'bool'], ['allowbrowser', T('Przeglądarka', 'Browser'), 'bool'],
|
||||
['allowconsumeremail', T('Prywatna poczta', 'Consumer email'), 'bool'], ['allowinternetsharing', T('Udostępnianie internetu', 'Internet sharing'), 'bool'],
|
||||
['allowremotedesktop', T('Pulpit zdalny', 'Remote desktop'), 'bool'], ['allowdesktopsync', T('Synchronizacja z PC', 'Desktop sync'), 'bool'],
|
||||
['allowunsignedapps', T('Niepodpisane aplikacje', 'Unsigned apps'), 'bool'], ['allowunsignedinstallationpackages', T('Niepodpisane pakiety', 'Unsigned packages'), 'bool'],
|
||||
['reqmansyncroam', T('Ręczna synchronizacja w roamingu', 'Manual sync when roaming'), 'bool'], ['reqsignedsmimemessages', T('Wymagaj podpisu S/MIME', 'Require signed S/MIME'), 'bool'],
|
||||
['reqencsmimemessages', T('Wymagaj szyfrowania S/MIME', 'Require encrypted S/MIME'), 'bool'], ['allowsmimesoftcerts', T('Certyfikaty programowe S/MIME', 'S/MIME soft certs'), 'bool'],
|
||||
];
|
||||
|
||||
PAGES.policies = async (v) => {
|
||||
let sel = 0;
|
||||
await settingsPage(v, 'PROVISIONING', T('Polityki urządzeń', 'Device policies'), T('Polityki bezpieczeństwa przekazywane urządzeniom (odpowiednik policies.ini). Zmiana polityki wymusza ponowny provisioning urządzeń.', 'Security policies pushed to devices (policies.ini equivalent). Changing a policy forces devices to re-provision.'), (s) => {
|
||||
const pols = s.provisioning.policies;
|
||||
return section(s, 'Provisioning', '', [
|
||||
['provisioning.enabled', T('Wymuszaj provisioning', 'Enforce provisioning'), 'bool'],
|
||||
['provisioning.loose', T('Luźny provisioning (urządzenia bez obsługi polityk)', 'Loose provisioning (devices without policy support)'), 'bool'],
|
||||
['provisioning.default_policy', T('Domyślna polityka', 'Default policy'), 'select', { options: pols.map((p) => [p.name, p.name]) }],
|
||||
]) + `<div class="card"><div class="section-title"><h2>${T('Polityka', 'Policy')}</h2><div class="top-actions"><select id="pol-sel">${pols.map((p, i) => `<option value="${i}">${esc(p.name)}</option>`).join('')}</select><button class="quiet" id="pol-add">${T('Duplikuj', 'Duplicate')}</button><button class="danger" id="pol-del">${T('Usuń', 'Delete')}</button></div></div><div id="pol-form"></div></div>
|
||||
<div class="card"><div class="section-title"><div><h2>${T('Przypisania użytkowników', 'User assignments')}</h2><p class="fine">${T('Jedna linia: login=nazwa_polityki', 'One per line: login=policy_name')}</p></div></div><textarea id="pol-users" spellcheck="false">${esc(s.provisioning.user_policies.map((u) => `${u.username}=${u.policy}`).join('\n'))}</textarea></div>`;
|
||||
}, {
|
||||
bind(form, s) {
|
||||
const pf = $('#pol-form', form);
|
||||
const drawPol = () => {
|
||||
const p = s.provisioning.policies[sel];
|
||||
pf.innerHTML = `<div class="form-grid">${fieldHtml({ p }, ['p.name', T('Nazwa', 'Name'), 'text'])}${POLICY_FIELDS().map((f) => fieldHtml({ p }, [`p.${f[0]}`, f[1], f[2]])).join('')}</div>`;
|
||||
$$('[data-path]', pf).forEach((el) => { el.dataset.pol = el.dataset.path.slice(2); el.removeAttribute('data-path'); });
|
||||
};
|
||||
const keep = () => {
|
||||
const p = s.provisioning.policies[sel];
|
||||
$$('[data-pol]', pf).forEach((el) => {
|
||||
const k = el.dataset.pol;
|
||||
p[k] = el.type === 'checkbox' ? el.checked : el.type === 'number' ? Number(el.value) : el.value.trim();
|
||||
});
|
||||
};
|
||||
$('#pol-sel', form).addEventListener('change', (e) => { keep(); sel = Number(e.target.value); drawPol(); });
|
||||
$('#pol-add', form).addEventListener('click', () => {
|
||||
keep();
|
||||
const c = JSON.parse(JSON.stringify(s.provisioning.policies[sel]));
|
||||
c.name = `${c.name}-copy`;
|
||||
s.provisioning.policies.push(c);
|
||||
sel = s.provisioning.policies.length - 1;
|
||||
$('#pol-sel', form).insertAdjacentHTML('beforeend', `<option value="${sel}">${esc(c.name)}</option>`);
|
||||
$('#pol-sel', form).value = String(sel);
|
||||
drawPol();
|
||||
$('#dirty').hidden = false;
|
||||
});
|
||||
$('#pol-del', form).addEventListener('click', () => {
|
||||
if (s.provisioning.policies.length < 2) { toast(T('Musi zostać przynajmniej jedna polityka', 'At least one policy is required'), true); return; }
|
||||
s.provisioning.policies.splice(sel, 1);
|
||||
sel = 0;
|
||||
$('#pol-sel', form).innerHTML = s.provisioning.policies.map((p, i) => `<option value="${i}">${esc(p.name)}</option>`).join('');
|
||||
drawPol();
|
||||
$('#dirty').hidden = false;
|
||||
});
|
||||
drawPol();
|
||||
this.keep = keep;
|
||||
this.s = s;
|
||||
},
|
||||
collect(form, cur) {
|
||||
this.keep();
|
||||
cur.provisioning.policies = this.s.provisioning.policies;
|
||||
cur.provisioning.user_policies = $('#pol-users', form).value.split('\n').map((l) => l.trim()).filter((l) => l.includes('=')).map((l) => { const [u, p] = l.split('='); return { username: u.trim(), policy: p.trim() }; });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
PAGES.security = async (v) => {
|
||||
await settingsPage(v, 'HARDENING', T('Bezpieczeństwo', 'Security'), T('Ochrona przed zgadywaniem haseł, kontrola dostępu sieciowego, urządzeń i panelu.', 'Brute-force protection, network/device access control and panel security.'), (s) =>
|
||||
section(s, T('Ochrona logowania', 'Login protection'), T('Po przekroczeniu limitu konto (i IP przy 3× limicie) jest blokowane. Linia logu jest zgodna z fail2ban.', 'Exceeding the limit locks the account (and the IP at 3× the limit). The log line is fail2ban friendly.'), [
|
||||
['security.auth_fail_max', T('Maks. błędnych logowań', 'Max failed logins'), 'number'], ['security.auth_fail_window_secs', T('Okno (s)', 'Window (s)'), 'number'],
|
||||
['security.lockout_secs', T('Czas blokady (s)', 'Lockout (s)'), 'number'], ['security.auth_cache_secs', T('Cache poprawnego logowania (s)', 'Successful login cache (s)'), 'number', { help: T('Przechowywany jest tylko HMAC, nigdy hasło.', 'Only an HMAC is kept, never the password.') }],
|
||||
['security.log_auth_failures', T('Loguj nieudane logowania', 'Log failed logins'), 'bool'],
|
||||
]) +
|
||||
section(s, T('Sieć', 'Network'), '', [
|
||||
['security.trusted_proxies', T('Zaufane proxy (CIDR)', 'Trusted proxies (CIDR)'), 'list', { help: T('Tylko od nich akceptowany jest nagłówek z prawdziwym IP klienta.', 'Only these may set the real client IP header.') }],
|
||||
['security.real_ip_header', T('Nagłówek z IP klienta', 'Client IP header'), 'text'],
|
||||
['security.eas_allowed_networks', T('Sieci z dostępem do ActiveSync (puste = wszystkie)', 'Networks allowed to use ActiveSync (empty = all)'), 'list'],
|
||||
['panel.allowed_networks', T('Sieci z dostępem do panelu (puste = wszystkie)', 'Networks allowed to use the panel (empty = all)'), 'list'],
|
||||
]) +
|
||||
section(s, T('Użytkownicy i urządzenia', 'Users & devices'), '', [
|
||||
['security.require_device_approval', T('Nowe urządzenia wymagają zatwierdzenia', 'New devices require approval'), 'bool'],
|
||||
['security.allowed_users', T('Tylko ci użytkownicy (puste = wszyscy)', 'Only these users (empty = all)'), 'list'],
|
||||
['security.blocked_users', T('Zablokowani użytkownicy', 'Blocked users'), 'list'],
|
||||
['security.blocked_device_types', T('Zablokowane typy urządzeń / User-Agent', 'Blocked device types / User-Agent'), 'list', { ph: 'WindowsMail\nOutlook' }],
|
||||
]) +
|
||||
section(s, T('Panel', 'Panel'), '', [
|
||||
['panel.session_ttl_minutes', T('Wygasanie sesji (min)', 'Session lifetime (min)'), 'number'],
|
||||
['panel.prometheus_token', T('Token Prometheus (/metrics)', 'Prometheus token (/metrics)'), 'secret', { help: T('Puste = endpoint wyłączony. Użycie: Authorization: Bearer <token>', 'Empty = endpoint disabled. Use: Authorization: Bearer <token>') }],
|
||||
]) +
|
||||
`<div class="card"><div class="section-title"><h2>${T('Konto administratora', 'Administrator account')}</h2></div>
|
||||
<div class="form-grid"><div class="field"><label>${T('Obecne hasło', 'Current password')}</label><input type="password" id="pw-cur" autocomplete="current-password"></div>
|
||||
<div class="field"><label>${T('Nowe hasło (min. 12 znaków)', 'New password (min. 12 chars)')}</label><input type="password" id="pw-new" autocomplete="new-password"></div>
|
||||
<div class="field"><label>${T('Powtórz', 'Repeat')}</label><input type="password" id="pw-new2" autocomplete="new-password"></div></div>
|
||||
<div class="form-actions"><button class="quiet" id="revoke">${T('Wyloguj inne sesje', 'Sign out other sessions')}</button><button class="outline" id="pw-save">${T('Zmień hasło', 'Change password')}</button></div></div>`,
|
||||
{
|
||||
bind(form) {
|
||||
$('#pw-save', form).addEventListener('click', async () => {
|
||||
const n = $('#pw-new', form).value;
|
||||
if (n !== $('#pw-new2', form).value) { toast(T('Hasła się różnią', 'Passwords differ'), true); return; }
|
||||
try { await api('POST', '/api/password', { current: $('#pw-cur', form).value, new: n }); toast(T('Hasło zmienione', 'Password changed')); $$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => { i.value = ''; }); } catch (e) { toast(e.message, true); }
|
||||
});
|
||||
$('#revoke', form).addEventListener('click', async () => { await api('POST', '/api/sessions/revoke-others'); toast(T('Inne sesje wylogowane', 'Other sessions revoked')); });
|
||||
// password fields are not settings
|
||||
$$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => i.addEventListener('input', (e) => e.stopPropagation()));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
PAGES.logs = async (v) => {
|
||||
v.innerHTML = head(T('DIAGNOSTYKA', 'DIAGNOSTICS'), T('Logi i audyt', 'Logs & audit'), T('Ostatnie wpisy logu serwera oraz dziennik działań administracyjnych.', 'Recent server log lines and the administrative audit trail.')) +
|
||||
`<div class="tabs"><button class="tab active" data-tab="log">${T('Log serwera', 'Server log')}</button><button class="tab" data-tab="audit">${T('Audyt', 'Audit')}</button></div>
|
||||
<div class="card" id="tab-log"><div class="toolbar"><select id="lv"><option value="">${T('wszystkie', 'all')}</option><option value="INFO">INFO+</option><option value="WARN">WARN+</option><option value="ERROR">ERROR</option></select><input id="lq" placeholder="${T('szukaj', 'search')}"><label class="check"><input type="checkbox" id="lf" checked> ${T('auto-odświeżanie', 'auto refresh')}</label></div><div class="feed" id="logbox"></div></div>
|
||||
<div class="card" id="tab-audit" hidden><div class="table-scroll" id="auditbox"></div></div>`;
|
||||
$$('.tab', v).forEach((t) => t.addEventListener('click', () => {
|
||||
$$('.tab', v).forEach((x) => x.classList.toggle('active', x === t));
|
||||
$('#tab-log').hidden = t.dataset.tab !== 'log';
|
||||
$('#tab-audit').hidden = t.dataset.tab !== 'audit';
|
||||
}));
|
||||
async function logs() {
|
||||
if (!$('#lf').checked) return;
|
||||
const q = new URLSearchParams({ n: '800' });
|
||||
if ($('#lv').value) q.set('level', $('#lv').value);
|
||||
if ($('#lq').value.trim()) q.set('q', $('#lq').value.trim());
|
||||
const l = await api('GET', `/api/logs?${q}`);
|
||||
const box = $('#logbox');
|
||||
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30;
|
||||
box.innerHTML = l.map((x) => `<div class="feed-row"><span class="t">${esc(fmtTime(x.ts))}</span><span class="${x.level === 'ERROR' || x.level === 'WARN' ? 'err' : 'cmd'}">${esc(x.level)}</span><span>${esc(x.message)}</span><span></span><span></span></div>`).join('') || `<div class="empty">-</div>`;
|
||||
if (atBottom) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
async function audit() {
|
||||
const a = await api('GET', '/api/audit');
|
||||
$('#auditbox').innerHTML = a.length ? `<table><thead><tr><th>${T('Czas', 'Time')}</th><th>${T('Kto', 'Actor')}</th><th>${T('Akcja', 'Action')}</th><th>${T('Cel', 'Target')}</th><th>IP</th><th>${T('Szczegóły', 'Details')}</th></tr></thead><tbody>${a.map((e) => `<tr><td>${esc(fmtTs(e.ts))}</td><td>${esc(e.actor)}</td><td>${esc(e.action)}</td><td>${esc(e.target)}</td><td>${esc(e.ip)}</td><td>${esc(e.details)}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">-</div>`;
|
||||
}
|
||||
$('#lv').addEventListener('change', logs);
|
||||
$('#lq').addEventListener('input', () => { clearTimeout(PAGES._lq); PAGES._lq = setTimeout(logs, 300); });
|
||||
every(3000, () => logs().catch(() => {}));
|
||||
every(30000, () => audit().catch(() => {}));
|
||||
};
|
||||
|
||||
PAGES.about = async (v) => {
|
||||
const o = await api('GET', '/api/overview');
|
||||
v.innerHTML = head('ABOUT', 'ActivePush', T('Serwer Exchange ActiveSync dla IMAP/SMTP + CalDAV/CardDAV - przepisany z Z-Push na Rust.', 'Exchange ActiveSync server for IMAP/SMTP + CalDAV/CardDAV - Z-Push rewritten in Rust.')) +
|
||||
`<div class="grid two"><div class="card"><h2>${T('Wersja', 'Version')} ${esc(o.version)}</h2><dl class="kv">
|
||||
<dt>ActiveSync</dt><dd>2.5, 12.0, 12.1, 14.0, 14.1</dd>
|
||||
<dt>${T('Komendy', 'Commands')}</dt><dd>Sync, FolderSync, FolderCreate/Update/Delete, Ping, Provision, SendMail, SmartReply, SmartForward, GetAttachment, ItemOperations, GetItemEstimate, MoveItems, MeetingResponse, Search (GAL, Mailbox), ResolveRecipients, Settings, ValidateCert</dd>
|
||||
<dt>Endpoints</dt><dd>/Microsoft-Server-ActiveSync<br>/autodiscover/autodiscover.xml (+ AutoDiscover/AutoDiscover.xml, Autodiscover/Autodiscover.xml)</dd>
|
||||
<dt>CLI</dt><dd>active-push admin | config | device | probe</dd></dl></div>
|
||||
<div class="card"><h2>nginx</h2><p class="fine">${T('Zastępuje lokalizacje fastcgi Z-Push:', 'Replaces the Z-Push fastcgi locations:')}</p><pre class="command">location = /Microsoft-Server-ActiveSync {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3660s;
|
||||
proxy_buffering off;
|
||||
client_max_body_size 64m;
|
||||
}
|
||||
location ~* ^/autodiscover/autodiscover\\.xml$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
}</pre></div></div>`;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ auth / boot
|
||||
function setAuthTexts(setup) {
|
||||
$('#auth-eyebrow').textContent = setup ? T('PIERWSZE URUCHOMIENIE', 'FIRST RUN') : T('ADMINISTRACJA', 'ADMINISTRATION');
|
||||
$('#auth-title').textContent = setup ? T('Utwórz administratora', 'Create administrator') : T('Zaloguj się', 'Sign in');
|
||||
$('#setup-help').textContent = T('Wygeneruj jednorazowy token na serwerze i wklej go poniżej:', 'Generate a one-time token on the server and paste it below:');
|
||||
$('#lbl-token').textContent = T('Token instalacyjny', 'Setup token');
|
||||
$('#lbl-user').textContent = T('Login', 'Username');
|
||||
$('#lbl-pass').textContent = setup ? T('Hasło (min. 12 znaków)', 'Password (min. 12 characters)') : T('Hasło', 'Password');
|
||||
$('#lbl-pass2').textContent = T('Powtórz hasło', 'Repeat password');
|
||||
$('#auth-submit').textContent = setup ? T('Utwórz i zaloguj', 'Create & sign in') : T('Zaloguj', 'Sign in');
|
||||
$('#setup-fields').hidden = !setup;
|
||||
$('#confirm-wrap').hidden = !setup;
|
||||
$('#password').autocomplete = setup ? 'new-password' : 'current-password';
|
||||
}
|
||||
|
||||
function showAuth(setup) {
|
||||
clearTimers();
|
||||
$('#loading').hidden = true;
|
||||
$('#shell').hidden = true;
|
||||
$('#auth').hidden = false;
|
||||
S.setup = !!setup;
|
||||
setAuthTexts(S.setup);
|
||||
$('#username').focus();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
let sess;
|
||||
try { sess = await api('GET', '/api/session'); } catch (e) { $('#loading').textContent = e.message; return; }
|
||||
if (!sess.authenticated) { showAuth(sess.setup_required); return; }
|
||||
S.session = sess;
|
||||
S.csrf = sess.csrf;
|
||||
$('#loading').hidden = true;
|
||||
$('#auth').hidden = true;
|
||||
$('#shell').hidden = false;
|
||||
$('#whoami').textContent = sess.username;
|
||||
$('#logout').textContent = T('Zablokuj', 'Lock');
|
||||
$('#theme').title = T('Motyw jasny/ciemny', 'Light/dark theme');
|
||||
document.title = `${sess.server_name || 'ActivePush'} / Control room`;
|
||||
go();
|
||||
}
|
||||
|
||||
$('#auth-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = $('#auth-msg');
|
||||
msg.textContent = '';
|
||||
const body = { username: $('#username').value.trim(), password: $('#password').value, token: $('#setup-token').value.trim() };
|
||||
if (S.setup && body.password !== $('#password2').value) { msg.textContent = T('Hasła się różnią', 'Passwords differ'); return; }
|
||||
$('#auth-submit').disabled = true;
|
||||
try {
|
||||
await api('POST', S.setup ? '/api/setup' : '/api/login', body);
|
||||
$('#password').value = '';
|
||||
$('#password2').value = '';
|
||||
await boot();
|
||||
} catch (err) { msg.textContent = err.message; } finally { $('#auth-submit').disabled = false; }
|
||||
});
|
||||
|
||||
$('#logout').addEventListener('click', async () => { try { await api('POST', '/api/logout'); } catch { /* ignore */ } showAuth(false); });
|
||||
$('#menu-toggle').addEventListener('click', () => $('#nav').classList.toggle('open'));
|
||||
const theme = store.get('ap.theme', 'dark');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
$('#theme').addEventListener('click', () => {
|
||||
const t = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = t;
|
||||
store.set('ap.theme', t);
|
||||
if (S.session) go();
|
||||
});
|
||||
const langSel = $('#lang');
|
||||
langSel.value = LANG;
|
||||
document.documentElement.lang = LANG;
|
||||
langSel.addEventListener('change', () => { LANG = langSel.value; store.set('ap.lang', LANG); location.reload(); });
|
||||
window.addEventListener('hashchange', () => { if (S.session) go(); });
|
||||
let resizeT;
|
||||
window.addEventListener('resize', () => { clearTimeout(resizeT); resizeT = setTimeout(() => { if (S.session && S.page === 'overview') go(); }, 400); });
|
||||
boot();
|
||||
})();
|
||||
Reference in new issue
Block a user