diff --git a/README.md b/README.md
index 44b99a0..af93a1d 100644
--- a/README.md
+++ b/README.md
@@ -314,6 +314,22 @@ base64url-декодировать, отбросить первые 4 байта
| `DELETE /ui/devices/:id` | Отвязать устройство |
| `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства |
+## Журнал подключений
+
+Вкладка «Подключения» показывает по выбранному серверу:
+
+- **События** — кто подключился к VPN и отключился, когда, сколько длилась сессия и сколько
+ скачал и отдал. Поиск — по имени пользователя, мастер-ключу, устройству или внутреннему IP.
+- **Кто когда был** — все пользователи и устройства sen:// сервера: онлайн сейчас, когда были на
+ связи последний раз и кто не подключался ни разу с начала журнала.
+
+Журнал ведёт awg-ctrl каждого сервера (`conn_log` в `users.db`, 90 дней). У WireGuard нет событий
+подключения, поэтому раз в 30 с он сверяет время handshake и счётчики трафика: «подключился» —
+время первого handshake, «отключился» — последняя активность (точность около 30 с). Перезапуск
+awg-ctrl сессии не рвёт, падение интерфейса отключением не считается. Внешний IP клиентов не
+записывается. Тот же журнал по API: `GET /api/connlog?limit=&before=&q=`. Серверы на прежней
+версии журнала не ведут — вкладка попросит их обновить.
+
## Управление
```bash
diff --git a/awg-ui/src/components/icons.tsx b/awg-ui/src/components/icons.tsx
index 1882371..8a663f8 100644
--- a/awg-ui/src/components/icons.tsx
+++ b/awg-ui/src/components/icons.tsx
@@ -108,10 +108,17 @@ export const IcoMove = () => (
);
+export const IcoHistory = () => (
+
+);
+
export const ICONS: Record JSX.Element> = {
users: IcoUsers,
key: IcoKey,
shield: IcoShield,
pulse: IcoPulse,
move: IcoMove,
+ history: IcoHistory,
};
\ No newline at end of file
diff --git a/awg-ui/src/tabs/connlog/connlog.css b/awg-ui/src/tabs/connlog/connlog.css
new file mode 100644
index 0000000..03e1425
--- /dev/null
+++ b/awg-ui/src/tabs/connlog/connlog.css
@@ -0,0 +1,46 @@
+/* Вкладка «Подключения»: лента событий и список «кто когда был». Строки — гибкие, без таблицы:
+ на телефоне колонки сами переносятся. */
+.cl { display: flex; flex-direction: column; gap: 12px; }
+
+.cl-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
+.cl-views { display: flex; gap: 8px; }
+.cl-view {
+ min-height: 44px; padding: 0 18px;
+ font: inherit; font-size: 14px; color: var(--on-surface);
+ background: var(--surface-bright);
+ border: 1px solid var(--outline); border-radius: var(--r-pill);
+ cursor: pointer;
+}
+.cl-view:hover { background: var(--state-primary); }
+.cl-view--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
+.cl-search { flex: 1; min-width: 200px; width: auto; }
+
+.cl-hint { margin: 0; font-size: 13px; color: var(--neutral-text); }
+.cl-err { text-align: left; }
+
+.cl-list {
+ list-style: none; margin: 0; padding: 0;
+ background: var(--surface-bright);
+ border: 1px solid var(--outline-variant); border-radius: var(--r-card);
+ overflow: hidden;
+}
+.cl-row {
+ display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--outline-variant);
+}
+.cl-row:first-child { border-top: none; }
+.cl-time { flex: 0 0 110px; font-size: 13px; color: var(--neutral-text); font-variant-numeric: tabular-nums; }
+.cl-chip { flex: none; }
+.cl-who { flex: 1 1 200px; display: flex; flex-direction: column; min-width: 0; }
+.cl-who b { font-weight: 500; overflow-wrap: anywhere; }
+.cl-sub { font-size: 12px; color: var(--neutral-text); overflow-wrap: anywhere; }
+.cl-session, .cl-seen { font-size: 13px; color: var(--on-surface); font-variant-numeric: tabular-nums; }
+.cl-empty { padding: 24px 16px; text-align: center; color: var(--neutral-text); }
+.cl-more { display: flex; justify-content: center; padding: 12px; border-top: 1px solid var(--outline-variant); }
+
+@media (max-width: 640px) {
+ .cl-time { flex-basis: auto; }
+ .cl-views { width: 100%; }
+ .cl-view { flex: 1; }
+}
diff --git a/awg-ui/src/tabs/connlog/index.tsx b/awg-ui/src/tabs/connlog/index.tsx
new file mode 100644
index 0000000..f94232f
--- /dev/null
+++ b/awg-ui/src/tabs/connlog/index.tsx
@@ -0,0 +1,183 @@
+// Copyright (c) 2026 Ivan Vasilev
+// This source code is licensed under the MIT license found in the
+// LICENSE file in the root directory of this source tree.
+import { useState, useEffect, useCallback, useMemo } from 'react';
+import { apiFetch, bytes, timeAgo, type PageProps } from '../../lib/shared';
+import { IcoRefresh } from '../../components/icons';
+import './connlog.css';
+
+// Вкладка «Подключения»: журнал VPN выбранного сервера. Ведёт его awg-ctrl (internal/ctrl/connlog.go)
+// по handshake и счётчикам WireGuard раз в 30 с, панель подставляет имена ключей и устройств sen://
+// (/ui/connlog). Время отключения — последняя активность, точность около 30 с.
+interface Who { pub_key: string; name: string; owner: string; ip: string; key?: string | null; device?: string | null; foreign?: boolean }
+interface Ev extends Who { id: number; at: number; event: 'connect' | 'disconnect'; duration?: number | null; rx?: number | null; tx?: number | null }
+interface Peer extends Who { online: boolean; last_seen: number | null }
+interface Log { events: Ev[]; peers: Peer[]; since: number | null; keep_days: number }
+
+const errText = (e: any): string => e?.response?.data?.error ?? 'Не удалось получить журнал';
+
+// Кто это: пользователь — по имени, устройство sen:// — ключ и устройство.
+function who(w: Who): { title: string; sub: string } {
+ if (w.name) return { title: w.name, sub: 'пользователь' };
+ if (w.foreign) return { title: 'Устройство другой панели', sub: w.owner };
+ if (w.owner) {
+ return { title: w.device || 'Удалённое устройство', sub: w.key ? `мастер-ключ «${w.key}»` : 'мастер-ключ удалён' };
+ }
+ return { title: `ключ ${w.pub_key.slice(0, 10)}…`, sub: 'не из панели' };
+}
+
+const pad = (n: number) => String(n).padStart(2, '0');
+function when(ts: number): string {
+ const d = new Date(ts * 1000), now = new Date();
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ if (d.toDateString() === now.toDateString()) return `сегодня ${time}`;
+ const y = new Date(now); y.setDate(now.getDate() - 1);
+ if (d.toDateString() === y.toDateString()) return `вчера ${time}`;
+ return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}${d.getFullYear() === now.getFullYear() ? '' : '.' + d.getFullYear()} ${time}`;
+}
+function duration(s: number): string {
+ if (s < 60) return `${s} сек`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m} мин`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h} ч ${m % 60} мин`;
+ return `${Math.floor(h / 24)} дн ${h % 24} ч`;
+}
+
+export default function ConnLogPage({ token, showMsg }: PageProps) {
+ const [view, setView] = useState<'events' | 'peers'>('events');
+ const [log, setLog] = useState(null);
+ const [more, setMore] = useState([]); // подгруженные страницы старее первой
+ const [done, setDone] = useState(false); // старее событий нет
+ const [err, setErr] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [query, setQuery] = useState('');
+
+ // Первая страница: обновляем раз в 30 с — журнал пишется с тем же шагом.
+ const load = useCallback(async () => {
+ try {
+ const l = await apiFetch('GET', '/ui/connlog?limit=200', token) as Log;
+ setLog(l); setErr('');
+ if (l.events.length < 200) setDone(true);
+ } catch (e) { setErr(errText(e)); }
+ }, [token]);
+ useEffect(() => {
+ void load();
+ const t = setInterval(() => { void load(); }, 30000);
+ return () => clearInterval(t);
+ }, [load]);
+
+ const all = useMemo(() => {
+ const first = log?.events ?? [];
+ const seen = new Set(first.map(e => e.id));
+ return [...first, ...more.filter(e => !seen.has(e.id))];
+ }, [log, more]);
+
+ const loadMore = async () => {
+ const last = all[all.length - 1];
+ if (!last) return;
+ setBusy(true);
+ try {
+ const l = await apiFetch('GET', `/ui/connlog?limit=200&before=${last.id}`, token) as Log;
+ setMore(m => [...m, ...l.events]);
+ if (l.events.length < 200) setDone(true);
+ } catch (e) { showMsg(errText(e)); }
+ finally { setBusy(false); }
+ };
+
+ const q = query.trim().toLowerCase();
+ const match = (w: Who) => !q || [w.name, w.owner, w.ip, w.key ?? '', w.device ?? '', w.pub_key]
+ .some(s => s.toLowerCase().includes(q));
+
+ const events = all.filter(match);
+ const peers = (log?.peers ?? []).filter(match).sort((a, b) =>
+ Number(b.online) - Number(a.online) || (b.last_seen ?? 0) - (a.last_seen ?? 0));
+ const onlineNow = (log?.peers ?? []).filter(p => p.online).length;
+
+ return (
+
+
+
+
+
+
+ setQuery(e.target.value)} />
+
+
+
+ {err &&
{err}
}
+ {!log && !err &&
Загрузка…
}
+
+ {log && (
+
+ Сейчас онлайн: {onlineNow} из {log.peers.length}.
+ {' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней.> : 'Журнал пуст: события появятся при первом подключении.'}
+ {' '}Время отключения — последняя активность, точность около 30 секунд.
+