VPN connection log: who connected and when
WireGuard has no connect events, only the last handshake and byte counters. awg-ctrl now checks them every 30 s and writes conn_log in users.db: connect at the first handshake, disconnect at the last activity (the last tick the counters moved), with the session length and traffic, plus peer_seen, the last time each peer was online. Open sessions are restored from the database, so a restart adds no duplicates; a down interface is not a disconnect; a peer removed mid-session is closed. Events keep the name, owner and VPN IP as of that moment and live 90 days. Client public IPs are not recorded. GET /api/connlog serves the events and the per-peer last-seen list. The panel fetches it from the selected server at /ui/connlog and fills in master key labels and device names for sen:// peers. A new "Подключения" tab shows the event feed with search and paging, and "who was online when", including those who never connected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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<Log | null>(null);
|
||||
const [more, setMore] = useState<Ev[]>([]); // подгруженные страницы старее первой
|
||||
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 (
|
||||
<div className="cl">
|
||||
<div className="cl-toolbar">
|
||||
<div className="cl-views" role="group" aria-label="Вид">
|
||||
<button type="button" className={`cl-view${view === 'events' ? ' cl-view--on' : ''}`} aria-pressed={view === 'events'}
|
||||
onClick={() => setView('events')}>События</button>
|
||||
<button type="button" className={`cl-view${view === 'peers' ? ' cl-view--on' : ''}`} aria-pressed={view === 'peers'}
|
||||
onClick={() => setView('peers')}>Кто когда был</button>
|
||||
</div>
|
||||
<input className="field cl-search" type="search" placeholder="Имя, ключ, устройство или IP"
|
||||
aria-label="Поиск" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<button className="btn btn--tonal" onClick={() => void load()} aria-label="Обновить"><IcoRefresh /> Обновить</button>
|
||||
</div>
|
||||
|
||||
{err && <p className="login-error cl-err">{err}</p>}
|
||||
{!log && !err && <p className="cl-hint">Загрузка…</p>}
|
||||
|
||||
{log && (
|
||||
<p className="cl-hint">
|
||||
Сейчас онлайн: <b>{onlineNow}</b> из {log.peers.length}.
|
||||
{' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней.</> : 'Журнал пуст: события появятся при первом подключении.'}
|
||||
{' '}Время отключения — последняя активность, точность около 30 секунд.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{log && view === 'events' && (
|
||||
<ul className="cl-list">
|
||||
{events.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'Событий пока нет'}</li>}
|
||||
{events.map(e => {
|
||||
const w = who(e);
|
||||
const on = e.event === 'connect';
|
||||
return (
|
||||
<li className="cl-row" key={e.id}>
|
||||
<span className="cl-time">{when(e.at)}</span>
|
||||
<span className={`chip chip--${on ? 'online' : 'offline'} cl-chip`}>
|
||||
<span className="chip-dot" />{on ? 'подключился' : 'отключился'}
|
||||
</span>
|
||||
<span className="cl-who">
|
||||
<b>{w.title}</b>
|
||||
<span className="cl-sub">{w.sub}{e.ip && <> · {e.ip}</>}</span>
|
||||
</span>
|
||||
{!on && (
|
||||
<span className="cl-session">
|
||||
{e.duration != null && <>сессия {duration(e.duration)}</>}
|
||||
{/* rx/tx — со стороны сервера: то, что сервер принял, клиент отправил */}
|
||||
{(e.tx || e.rx) ? <> · ↓ {bytes(e.tx ?? 0)} ↑ {bytes(e.rx ?? 0)}</> : null}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!done && !q && all.length > 0 && (
|
||||
<li className="cl-more">
|
||||
<button className="btn btn--tonal" onClick={() => void loadMore()} disabled={busy}>
|
||||
{busy ? 'Загрузка…' : 'Показать раньше'}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{log && view === 'peers' && (
|
||||
<ul className="cl-list">
|
||||
{peers.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'На сервере нет пользователей'}</li>}
|
||||
{peers.map(p => {
|
||||
const w = who(p);
|
||||
return (
|
||||
<li className="cl-row" key={p.pub_key}>
|
||||
<span className="cl-who">
|
||||
<b>{w.title}</b>
|
||||
<span className="cl-sub">{w.sub}{p.ip && <> · {p.ip}</>}</span>
|
||||
</span>
|
||||
{p.online ? (
|
||||
<span className="chip chip--online cl-chip"><span className="chip-dot" />онлайн</span>
|
||||
) : p.last_seen ? (
|
||||
<span className="cl-seen">был {when(p.last_seen)} <span className="cl-sub">({timeAgo(p.last_seen)} назад)</span></span>
|
||||
) : (
|
||||
<span className="chip chip--offline cl-chip"><span className="chip-dot" />не подключался{log.since ? ` с ${when(log.since)}` : ''}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "connlog",
|
||||
"label": "Подключения",
|
||||
"icon": "history",
|
||||
"group": "main",
|
||||
"order": 1.5
|
||||
}
|
||||
Reference in New Issue
Block a user