Merge origin/main into the 0.5.0 redesign
ci / check (push) Failing after 6m27s

Brings in 0.4.8: connection log, uninstall and update from the awg-ctrl menu,
secret panel path, stealth hub and sen:// port, random I1/MTU on install.
install.sh keeps the new output (spinner, cards, outdated-install wipe) on top
of the reorganised installer; an outdated install can be reinstalled with a
wipe or removed. The connection log tab uses the MD3 segmented button; its
metrics-token scrape snippet moved to Settings.
This commit is contained in:
2026-10-29 23:07:03 +03:00
40 changed files with 3639 additions and 780 deletions
+183
View File
@@ -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 { IcoCheck, 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="segmented cl-views" role="radiogroup" aria-label="Вид">
<button type="button" role="radio" aria-checked={view === 'events'} className="segmented-item"
onClick={() => setView('events')}>{view === 'events' && <IcoCheck />}События</button>
<button type="button" role="radio" aria-checked={view === 'peers'} className="segmented-item"
onClick={() => setView('peers')}>{view === 'peers' && <IcoCheck />}Кто когда был</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>
);
}