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:
@@ -0,0 +1,36 @@
|
||||
/* Вкладка «Подключения»: лента событий и список «кто когда был». Строки — гибкие, без таблицы:
|
||||
на телефоне колонки сами переносятся. */
|
||||
.cl { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.cl-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.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(--shape-large);
|
||||
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; min-width: 112px; }
|
||||
.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-views .segmented-item { 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 { 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "connlog",
|
||||
"label": "Подключения",
|
||||
"icon": "history",
|
||||
"group": "main",
|
||||
"order": 1.5
|
||||
}
|
||||
@@ -53,6 +53,22 @@ function savedSection(): SectionId {
|
||||
return 'look';
|
||||
}
|
||||
|
||||
// Фрагмент prometheus.yml для внешнего Prometheus. Адрес — от страницы: в нём уже секретный путь
|
||||
// панели (UI_PREFIX) или подпапка nginx, поэтому конфиг верен в обоих случаях.
|
||||
function scrapeConfig(tok: string): string {
|
||||
const u = new URL('metrics', location.href.split(/[?#]/)[0]);
|
||||
return [
|
||||
'scrape_configs:',
|
||||
' - job_name: vpn-panel',
|
||||
` scheme: ${u.protocol.replace(':', '')}`,
|
||||
` metrics_path: ${u.pathname}`,
|
||||
' authorization:',
|
||||
` credentials: ${tok}`,
|
||||
' static_configs:',
|
||||
` - targets: ['${u.host}']`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const THEMES: { id: ThemeMode; label: string }[] = [
|
||||
{ id: 'light', label: 'Светлая' },
|
||||
{ id: 'dark', label: 'Тёмная' },
|
||||
@@ -266,16 +282,19 @@ export default function SettingsPage({ token, showMsg }: PageProps) {
|
||||
<div className="qr-card" role="dialog" aria-modal="true" aria-labelledby="st-new-title" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name" id="st-new-title">Токен метрик выпущен</p>
|
||||
<p className="qr-hint">
|
||||
Скопируй его сейчас — он показывается один раз. В prometheus.yml он идёт
|
||||
в <code className="st-code">authorization.credentials</code>.
|
||||
Скопируй его сейчас — он показывается один раз. Готовый фрагмент для prometheus.yml:
|
||||
</p>
|
||||
<code className="st-secret">{created}</code>
|
||||
<pre className="st-secret st-yml">{scrapeConfig(created)}</pre>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
|
||||
<button className="btn btn--primary" onClick={async () => {
|
||||
<button className="btn btn--tonal" onClick={async () => {
|
||||
await copyText(created);
|
||||
showMsg('Токен скопирован');
|
||||
}}><IcoCopy /> Скопировать</button>
|
||||
}}><IcoCopy /> Токен</button>
|
||||
<button className="btn btn--primary" onClick={async () => {
|
||||
await copyText(scrapeConfig(created));
|
||||
showMsg('Фрагмент prometheus.yml скопирован');
|
||||
}}><IcoCopy /> Фрагмент</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,3 +117,6 @@ LICENSE file in the root directory of this source tree.
|
||||
.st-row-ctl { width: 100%; }
|
||||
.st-row-ctl .btn { flex: 1; }
|
||||
}
|
||||
|
||||
/* Фрагмент prometheus.yml в диалоге токена: переносы строк сохраняются */
|
||||
.st-yml { white-space: pre-wrap; margin: 0; text-align: left; }
|
||||
|
||||
Reference in New Issue
Block a user