first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-05 12:52:32 +01:00
commit 19dfc51360
85 files changed
+25504

No files matched your search

+931
View File
@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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();
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#1b1f1d"/><text x="32" y="41" text-anchor="middle" font-family="monospace" font-size="24" font-weight="700" fill="#f69b74">a<tspan fill="#cfe0ac">/</tspan>p</text></svg>

After

Width:  |  Height:  |  Size: 273 B

+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>ActivePush / Control room</title>
<link rel="icon" href="/favicon.svg">
<link rel="stylesheet" href="/style.css">
<script defer src="/app.js"></script>
</head>
<body>
<div class="app-loading" id="loading"><div><div class="spinner"></div>ACTIVEPUSH</div></div>
<div class="auth-page" id="auth" hidden>
<div class="card auth-card">
<div class="brand"><span class="brand-mark">a<span>/</span>p</span><span>ActivePush</span></div>
<div class="small-label" id="auth-eyebrow">ADMINISTRATION</div>
<h1 id="auth-title">Sign in</h1>
<form id="auth-form" autocomplete="on">
<div id="setup-fields" hidden>
<p class="fine" id="setup-help"></p>
<code class="command">sudo -u active-push active-push admin setup-token</code>
<label for="setup-token" id="lbl-token">Setup token</label>
<input id="setup-token" autocomplete="off" spellcheck="false" maxlength="64">
</div>
<div>
<label for="username" id="lbl-user">Username</label>
<input id="username" autocomplete="username" autocapitalize="none" spellcheck="false" maxlength="64" required>
</div>
<div>
<label for="password" id="lbl-pass">Password</label>
<input id="password" type="password" autocomplete="current-password" maxlength="256" required>
</div>
<div id="confirm-wrap" hidden>
<label for="password2" id="lbl-pass2">Repeat password</label>
<input id="password2" type="password" autocomplete="new-password" maxlength="256">
</div>
<button class="primary" type="submit" id="auth-submit">Sign in</button>
<p class="fine" id="auth-msg" role="alert"></p>
</form>
</div>
</div>
<div class="shell" id="shell" hidden>
<aside class="sidebar">
<a class="brand" href="#overview"><span class="brand-mark">a<span>/</span>p</span><span>ActivePush</span></a>
<button class="quiet mobile-toggle" id="menu-toggle" type="button">Menu</button>
<div class="edition">EXCHANGE ACTIVESYNC GATEWAY</div>
<nav class="main-navigation" id="nav"></nav>
<div class="sidebar-bottom">
<span class="small-label">RUNTIME</span>
<p>Rust / Tokio / Axum<br>IMAP · CalDAV · CardDAV<br>SQLite</p>
<div class="local-tag"><span class="signal pulse"></span><span id="push-count">PUSH</span></div>
</div>
</aside>
<main>
<header class="topbar">
<span class="breadcrumb">CONTROL ROOM <span>/</span> <b id="page-label">Overview</b></span>
<div class="top-actions">
<select id="lang" class="quiet" aria-label="Language"><option value="en">EN</option><option value="pl">PL</option></select>
<button class="quiet" id="theme" type="button">◐</button>
<span class="fine mono" id="whoami"></span>
<button class="quiet" id="logout" type="button">Lock</button>
</div>
</header>
<div id="view"></div>
</main>
</div>
<div id="modal-root"></div>
<div class="toast" id="toast" hidden></div>
</body>
</html>
+363
View File
@@ -0,0 +1,363 @@
/* ActivePush control room - visual language shared with RustProxy */
:root {
color-scheme: dark;
--bg: #131615;
--panel: #1b1f1d;
--panel2: #202622;
--line: #323a34;
--row-line: #2b332c;
--muted: #a0aaa0;
--subtle: #879082;
--text: #edf0e9;
--accent: #f69b74;
--accent-hover: #ffb090;
--accent-ink: #221810;
--green: #bdd293;
--red: #ffaaa2;
--blue: #8ebcff;
--violet: #d7adff;
--sand: #c9b99c;
--sidebar-bg: #101310;
--brand2: #cfe0ac;
--nav-active: #242b23;
--nav-hover: #20251f;
--field-bg: #131813;
--field-line: #47523f;
--outline: #717b67;
--grid: #333c33;
--code-bg: #101810;
--notice-bg: #29251c;
--notice-line: #554833;
--pill-line: #6d5a45;
--pill-live: #576847;
--danger-line: #79514e;
--shadow: #0009;
--backdrop: rgba(0, 0, 0, .68);
--r-surface: 6px;
--r-control: 4px;
--mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Consolas, monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
html[data-theme=light] {
color-scheme: light;
--bg: #f4f6f2;
--panel: #fff;
--panel2: #edf1eb;
--line: #cbd3c7;
--row-line: #e0e5dd;
--muted: #5e685f;
--subtle: #687369;
--text: #1d241f;
--accent: #b85b38;
--accent-hover: #a84f30;
--accent-ink: #fff;
--green: #5f783f;
--red: #b64a43;
--blue: #4f77a9;
--violet: #7d4fa3;
--sand: #685b45;
--sidebar-bg: #eef2ec;
--brand2: #6b8249;
--nav-active: #e1e8dc;
--nav-hover: #e8ede6;
--field-bg: #fff;
--field-line: #b7c1b5;
--outline: #98a497;
--grid: #d4dbd1;
--code-bg: #edf3e9;
--notice-bg: #fff9ef;
--notice-line: #d4c2a5;
--pill-line: #cbb28f;
--pill-live: #a8ba8d;
--danger-line: #d7a29d;
--shadow: rgba(31, 39, 33, .22);
--backdrop: rgba(31, 39, 33, .45);
}
* { box-sizing: border-box; scrollbar-color: #4f5d50 transparent; scrollbar-width: thin; }
[hidden] { display: none !important; }
html, body { margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 12px/1.5 var(--sans);
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
code, .mono { font-family: var(--mono); }
h1 { margin: 0; font-size: clamp(26px, 3vw, 40px); font-weight: 520; letter-spacing: -1.5px; line-height: 1.1; }
h2 { margin: 0; font-size: 15px; letter-spacing: -.3px; }
h3 { margin: 0 0 8px; font-size: 13px; }
/* ---------------------------------------------------------------- controls */
button { font: inherit; cursor: pointer; border: 0; }
button:disabled { opacity: .5; cursor: wait; }
.primary, .outline, .quiet, .danger {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
min-height: 27px; padding: 5px 11px; font-size: 11px; border-radius: var(--r-control); white-space: nowrap;
}
.primary { background: var(--accent); color: var(--accent-ink); border: 1px solid var(--accent); font-weight: 650; }
.primary:hover { background: var(--accent-hover); }
.outline { background: transparent; color: var(--text); border: 1px solid var(--outline); }
.outline:hover { border-color: var(--accent); }
.quiet { background: transparent; color: var(--muted); border: 1px solid var(--line); }
.quiet:hover { color: var(--text); border-color: var(--outline); }
.danger { background: transparent; color: var(--red); border: 1px solid var(--danger-line); }
.danger:hover { background: color-mix(in srgb, var(--red) 12%, transparent); }
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
input, select, textarea {
font: inherit; font-size: 12px; width: 100%; min-width: 0;
background: var(--field-bg); color: var(--text);
border: 1px solid var(--field-line); border-radius: var(--r-control);
padding: 6px 8px; min-height: 29px;
}
textarea { min-height: 70px; resize: vertical; font-family: var(--mono); }
input[type=checkbox] { width: 15px; height: 15px; min-height: 0; accent-color: var(--accent); }
label { display: block; color: var(--muted); font-size: 11px; margin: 0 0 3px; }
.small-label { font: 9px var(--mono); letter-spacing: 1.2px; color: var(--muted); text-transform: uppercase; }
.fine { font-size: 11px; color: var(--muted); line-height: 1.6; overflow-wrap: anywhere; }
.pill {
display: inline-block; padding: 2px 7px; border: 1px solid var(--pill-line); color: var(--accent);
font: 9px var(--mono); letter-spacing: .8px; border-radius: 3px; white-space: nowrap; text-transform: uppercase;
}
.pill.live, .pill.ok { border-color: var(--pill-live); color: var(--green); }
.pill.bad { border-color: var(--danger-line); color: var(--red); }
.pill.info { border-color: color-mix(in srgb, var(--blue) 45%, transparent); color: var(--blue); }
.pill.muted { border-color: var(--line); color: var(--muted); }
.signal { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
.signal.pulse { animation: pulse 1.6s infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 60%, transparent); } 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
.notice {
display: flex; gap: 12px; align-items: flex-start; color: var(--sand); border: 1px solid var(--notice-line);
background: var(--notice-bg); border-radius: var(--r-surface); padding: 8px 11px; margin-bottom: 10px; font-size: 11px; line-height: 1.6;
}
.notice b { font-family: var(--mono); color: var(--accent); }
/* ---------------------------------------------------------------- shell */
.shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-height: 100vh; }
.sidebar {
background: var(--sidebar-bg); border-right: 1px solid var(--line); padding: 18px 12px;
display: flex; flex-direction: column; gap: 14px; position: sticky; top: 0; height: 100vh; overflow-y: auto;
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 14px; letter-spacing: -.2px; }
.brand-mark {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; white-space: nowrap; width: 34px; height: 34px; border: 1px solid var(--line);
border-radius: 8px; font: 700 12px var(--mono); color: var(--accent); background: var(--panel);
}
.brand-mark span { color: var(--brand2); }
.edition { font: 9px var(--mono); letter-spacing: 1.6px; color: var(--subtle); }
.main-navigation { display: flex; flex-direction: column; gap: 2px; }
.nav {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: transparent;
color: var(--muted); padding: 7px 9px; border-radius: var(--r-control); font-size: 12px;
}
.nav span { font: 10px var(--mono); color: var(--subtle); width: 18px; }
.nav b { font-weight: 500; }
.nav:hover { background: var(--nav-hover); color: var(--text); }
.nav.active { background: var(--nav-active); color: var(--brand2); }
.nav.active span { color: var(--accent); }
.nav .count { margin-left: auto; font: 10px var(--mono); color: var(--accent); }
.sidebar-bottom { margin-top: auto; border-top: 1px solid var(--line); padding-top: 12px; }
.sidebar-bottom p { margin: 6px 0; color: var(--subtle); font: 10px/1.6 var(--mono); }
.local-tag { display: flex; align-items: center; gap: 6px; font: 9px var(--mono); letter-spacing: 1.2px; color: var(--green); }
.mobile-toggle { display: none; }
main { min-width: 0; padding: 0 24px 40px; }
.topbar {
display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 0;
border-bottom: 1px solid var(--line); margin-bottom: 18px; position: sticky; top: 0; background: var(--bg); z-index: 5;
}
.breadcrumb { font: 10px var(--mono); letter-spacing: 1.4px; color: var(--muted); }
.breadcrumb span { color: var(--subtle); margin: 0 4px; }
.breadcrumb b { color: var(--text); font-weight: 500; }
.top-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.top-actions select { width: auto; min-height: 27px; padding: 4px 8px; }
#theme { min-width: 30px; font-size: 13px; }
.page-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
.page-head .eyebrow { margin-bottom: 6px; }
.page-head p { margin: 6px 0 0; max-width: 720px; }
/* ---------------------------------------------------------------- cards / grid */
.grid { display: grid; gap: 10px; }
.grid.stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
#view { display: flex; flex-direction: column; gap: 12px; }
#view > .page-head, #view > .tabs { margin-bottom: 0; }
.stack { display: flex; flex-direction: column; gap: 12px; }
.stack > * + * { margin-top: 0; }
.grid.stats > .notice { grid-column: 1 / -1; margin: 0; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-surface); padding: 12px 14px; min-width: 0; }
.stack > * + * { margin-top: 10px; }
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.card-heading .small-label { display: block; margin-bottom: 2px; }
.stat { position: relative; overflow: hidden; }
.stat .value { font: 600 24px/1.15 var(--mono); letter-spacing: -1px; margin-top: 6px; }
.stat .value small { font-size: 12px; color: var(--muted); letter-spacing: 0; margin-left: 4px; }
.stat .sub { color: var(--muted); font-size: 10.5px; margin-top: 2px; }
.stat svg.spark { position: absolute; right: 0; bottom: 0; width: 55%; height: 34px; opacity: .55; pointer-events: none; }
.stat.accent .value { color: var(--accent); }
.stat.good .value { color: var(--green); }
.stat.bad .value { color: var(--red); }
/* ---------------------------------------------------------------- tables */
.table-scroll { overflow: auto; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th {
font: 9.5px var(--mono); letter-spacing: .6px; color: var(--muted); font-weight: 400; text-transform: uppercase;
border-bottom: 1px solid var(--line); padding: 7px 8px; text-align: left; white-space: nowrap; position: sticky; top: 0; background: var(--panel);
}
td { border-bottom: 1px solid var(--row-line); padding: 7px 8px; font-family: var(--mono); max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tr.clickable { cursor: pointer; }
tr.clickable:hover td { background: var(--nav-hover); }
td.num, th.num { text-align: right; }
td .sub { display: block; color: var(--muted); font-size: 10px; }
.empty { color: var(--muted); padding: 18px 4px; text-align: center; }
/* ---------------------------------------------------------------- forms */
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px 14px; }
.field.wide { grid-column: 1 / -1; }
.field .help { font-size: 10.5px; color: var(--subtle); margin-top: 3px; line-height: 1.45; }
.check { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 12px; margin-top: 18px; cursor: pointer; }
.check input { flex: 0 0 auto; }
.form-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-top: 12px; flex-wrap: wrap; }
.section-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin: 2px 0 10px; }
.dirty-flag { color: var(--accent); font: 10px var(--mono); }
.test-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); }
.result { margin-top: 8px; font: 11px/1.5 var(--mono); padding: 7px 9px; border-radius: var(--r-control); background: var(--code-bg); border: 1px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; }
.result.ok { color: var(--green); }
.result.bad { color: var(--red); }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 12px; flex-wrap: wrap; }
.tab { background: transparent; color: var(--muted); padding: 7px 11px; border-bottom: 2px solid transparent; font-size: 12px; }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
.toolbar input, .toolbar select { width: auto; min-width: 180px; }
/* ---------------------------------------------------------------- charts */
.chart { width: 100%; display: block; }
.chart text { fill: var(--muted); font: 9.5px var(--mono); }
.chart .grid-line { stroke: var(--grid); stroke-dasharray: 2 5; }
.chart-wrap { position: relative; }
.chart-tip {
position: absolute; pointer-events: none; background: var(--panel2); border: 1px solid var(--line); border-radius: var(--r-control);
padding: 5px 8px; font: 10.5px/1.5 var(--mono); box-shadow: 0 8px 20px var(--shadow); white-space: nowrap; z-index: 3;
}
.legend { display: flex; gap: 14px; flex-wrap: wrap; font: 10px var(--mono); color: var(--muted); margin-top: 6px; }
.legend i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
.hbar { display: grid; grid-template-columns: 120px 1fr 54px; gap: 8px; align-items: center; font: 11px var(--mono); margin: 5px 0; }
.hbar .track { height: 8px; background: var(--panel2); border-radius: 3px; overflow: hidden; }
.hbar .fill { height: 100%; background: var(--accent); border-radius: 3px; }
.hbar .n { text-align: right; color: var(--muted); }
.donut-layout { display: grid; grid-template-columns: 130px 1fr; gap: 14px; align-items: center; }
/* ---------------------------------------------------------------- live feed */
.feed { font: 11px/1.55 var(--mono); max-height: 460px; overflow: auto; }
.feed-row { display: grid; grid-template-columns: 64px minmax(90px, 3fr) minmax(90px, 2fr) 72px 64px; gap: 10px; padding: 3px 4px; border-bottom: 1px solid var(--row-line); }
.feed-row.fresh { animation: flash 1.2s ease-out; }
@keyframes flash { from { background: color-mix(in srgb, var(--accent) 22%, transparent); } to { background: transparent; } }
.feed-row .t { color: var(--subtle); }
.feed-row .cmd { color: var(--accent); }
.feed-row .err { color: var(--red); }
.countdown { height: 3px; background: var(--panel2); border-radius: 2px; overflow: hidden; margin-top: 3px; }
.countdown div { height: 100%; background: var(--green); }
/* ---------------------------------------------------------------- dialog / toast */
.backdrop { position: fixed; inset: 0; background: var(--backdrop); z-index: 40; display: grid; place-items: center; padding: 16px; }
.dialog {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; width: min(980px, 100%);
max-height: calc(100vh - 32px); overflow: auto; box-shadow: 0 24px 60px var(--shadow); padding: 16px 18px;
}
.dialog.small { width: min(460px, 100%); }
.dialog-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
.kv { display: grid; grid-template-columns: 150px 1fr; gap: 5px 12px; font-size: 11.5px; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; font-family: var(--mono); overflow-wrap: anywhere; }
.actions-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
.toast {
position: fixed; right: 18px; bottom: 18px; z-index: 60; background: #d0dfb4; color: #1c2818; border: 1px solid #ebf2de;
padding: 9px 13px; border-radius: 6px; box-shadow: 0 10px 28px var(--shadow); max-width: 420px; font-size: 12px;
}
.toast.error { background: #ffd2bd; color: #42241b; border-color: #ff9f7a; }
/* ---------------------------------------------------------------- auth */
.auth-page { display: grid; place-items: center; min-height: 100vh; padding: 16px; }
.auth-card { width: min(420px, 100%); }
.auth-card .brand { margin-bottom: 22px; }
.auth-card h1 { margin-bottom: 18px; }
.auth-card form > * + * { margin-top: 10px; }
.auth-card .primary { width: 100%; min-height: 34px; margin-top: 14px; }
.command { display: block; font: 11px var(--mono); background: var(--code-bg); border: 1px solid var(--line); padding: 7px 9px; border-radius: 4px; margin: 6px 0; overflow-wrap: anywhere; }
/* ---------------------------------------------------------------- loading */
.app-loading { display: grid; place-items: center; min-height: 100vh; color: var(--muted); font: 11px var(--mono); letter-spacing: 1.5px; }
.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------------------------------------------------------------- responsive */
@media (max-width: 1100px) {
.grid.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid.three { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 880px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; padding: 10px 14px; flex-direction: row; flex-wrap: wrap; align-items: center; }
.sidebar .edition, .sidebar-bottom { display: none; }
.mobile-toggle { display: inline-flex; margin-left: auto; }
.main-navigation { display: none; width: 100%; }
.main-navigation.open { display: flex; }
main { padding: 0 14px 30px; }
.grid.two, .grid.three { grid-template-columns: 1fr; }
.grid.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stat .value { font-size: 20px; }
.test-row { grid-template-columns: 1fr; }
.kv { grid-template-columns: 1fr; }
.feed-row { grid-template-columns: 56px 1fr 60px; }
.feed-row .u, .feed-row .ms { display: none; }
.donut-layout { grid-template-columns: 1fr; }
}
/* chart colour classes (CSP: no inline styles) */
.k1 { stroke: var(--accent); fill: var(--accent); }
.k2 { stroke: var(--green); fill: var(--green); }
.k3 { stroke: var(--blue); fill: var(--blue); }
.k4 { stroke: var(--violet); fill: var(--violet); }
.k5 { stroke: var(--red); fill: var(--red); }
.k6 { stroke: var(--sand); fill: var(--sand); }
.k7 { stroke: var(--muted); fill: var(--muted); }
.ln { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.ar { stroke: none; fill-opacity: .14; }
.br { stroke: none; }
.dn { fill: none; stroke-width: 14; }
.cursor { stroke: var(--outline); stroke-dasharray: 3 3; }
.sw.k1, .fill.k1 { background: var(--accent); }
.sw.k2, .fill.k2 { background: var(--green); }
.sw.k3, .fill.k3 { background: var(--blue); }
.sw.k4, .fill.k4 { background: var(--violet); }
.sw.k5, .fill.k5 { background: var(--red); }
.sw.k6, .fill.k6 { background: var(--sand); }
.sw.k7, .fill.k7 { background: var(--muted); }
.donut-center { fill: var(--text); font: 600 15px var(--mono); }
.mt { margin-top: 14px; }
.card-heading input { max-width: 340px; }
/* select dropdown colours (native popups ignore transparent backgrounds) */
select option, select optgroup { background: var(--panel); color: var(--text); }
select.quiet { background: var(--panel); color: var(--text); }
.feed-row > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip {
display: inline-flex; align-items: center; gap: 5px; margin: 1px 4px 1px 0; padding: 1px 7px; border: 1px solid var(--line);
border-radius: 10px; background: var(--panel2); color: var(--text); font: 10.5px var(--mono); cursor: pointer;
}
.chip:hover { border-color: var(--accent); }
.chip .signal { margin: 0; width: 6px; height: 6px; }
td.wrap { white-space: normal; max-width: 420px; }