Panel redesign, per-server I1 masking, node minimum version 0.5.0, installer output
ci / check (push) Successful in 23m54s

- UI: settings tab with sections (server, API keys, Prometheus, audit, migrate,
  about); server settings moved there; master keys table rework (menu, chips,
  version, compact phone cards); three-state node version chip
- I1: random per-server template (DNS / QUIC Initial / STUN) kept in users.db,
  GET/POST /api/masking, change from the master keys banner
- Hub rejects nodes older than 0.5.0 (close 4026), /ui/nodes reports outdated
- install.sh: spinner for long commands (full output in the log), step progress
  bar, cards; installs older than 0.5.0 require a full reinstall with data reset
- Bump to Beta 0.5.0
This commit is contained in:
2026-10-29 22:59:09 +03:00
parent c7d1333125
commit 5b29993d5d
57 changed files with 3154 additions and 1106 deletions
@@ -0,0 +1,97 @@
/*
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.
*/
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
/* Открытый API-ключ (показывается один раз при создании) */
.apikey-value {
font-family: var(--mono);
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-surface);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: var(--shape-medium);
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-empty {
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
}
.apikey-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--shape-extra-large);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.apikey-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.apikey-card-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.apikey-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label {
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: var(--weight-medium);
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apikey-card-prefix {
font-family: var(--mono);
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text);
word-break: break-all;
}
.apikey-card-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: var(--body-small-size); line-height: var(--body-small-line);
color: var(--neutral-text);
}
/* compact и medium (< 840px): карточки вместо таблицы, на планшете — сеткой */
@media (max-width: 839px) {
.apikey-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
/* счётчик и «пусто» — на всю строку сетки */
:is(.apikey-count, .apikey-empty) { grid-column: 1 / -1; }
}
/* Настройки ключа: какие серверы он видит */
.apikey-tr-tools td { padding-top: 0; }
.apikey-tools {
display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--shape-large);
padding: 12px 16px 14px;
margin: 6px 0 8px;
}
.apikey-tools-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
.apikey-tools-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
.apikey-card-servers { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; }
+207
View File
@@ -0,0 +1,207 @@
// 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 { Fragment, useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../../components/icons';
import ServerPicker, { serverName } from '../../../components/ServerPicker';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Раздел вкладки «Настройки».
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
// управляет мастер-ключами. По умолчанию новый ключ видит выбранный в панели сервер, набор
// правится в настройках ключа.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в панели
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch {
showMsg('Ошибка загрузки ключей');
}
}, [token, showMsg]);
const setServers = useCallback(async (k: ApiKey, next: number[], n: NodeInfo, added: boolean) => {
try {
await apiFetch('PATCH', `/ui/apikeys/${k.id}`, token, { servers: next });
showMsg(added ? `Ключ видит «${serverName(nodes, n.id)}»` : `«${serverName(nodes, n.id)}» убран из ключа`);
await load();
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
}
}, [token, nodes, load, showMsg]);
const serversText = (k: ApiKey) => (k.servers ?? [k.server_id]).map(id => serverName(nodes, id)).join(', ');
const toolbox = (k: ApiKey) => (
<section className="apikey-tools">
<h4 className="apikey-tools-title">Серверы ключа</h4>
<p className="apikey-tools-hint">
На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой
выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.
</p>
<ServerPicker nodes={nodes} selected={k.servers ?? [k.server_id]} onChange={(next, n, added) => setServers(k, next, n, added)} />
</section>
);
const createKey = useCallback(async () => {
if (!label.trim()) return;
const def = getServerId() ?? 0;
const servers = picked ?? [def];
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: servers.includes(def) ? def : servers[0], servers });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setPicked(null);
setCreated({ label: r.label, key: r.key });
await load();
}, [label, picked, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
showMsg('Ключ удалён: ' + lbl);
await load();
}, [token, load, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
return (
<>
<div className="toolbar">
<input
className="field"
placeholder="Метка ключа (напр. ci-bot)"
value={label}
onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createKey()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createKey}>
<IcoPlus /> Создать ключ
</button>
</div>
</div>
<div className="apikey-new-servers">
<span className="apikey-tools-hint">Серверы нового ключа:</span>
<ServerPicker nodes={nodes} selected={picked ?? [getServerId() ?? 0]}
onChange={next => setPicked(next)} />
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Серверы</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={7} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<Fragment key={k.id}>
<tr>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{serversText(k)}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon"
title="Серверы ключа"
aria-label="Серверы ключа"
aria-expanded={tools === k.id}
onClick={() => setTools(tools === k.id ? null : k.id)}
><IcoSettings /></button>
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
</Fragment>
))}
</tbody>
</table>
</div>
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
<div className="apikey-cards-list">
<p className="apikey-count">{keys.length} ключей</p>
{keys.length === 0 ? (
<p className="apikey-empty">Нет ключей</p>
) : keys.map((k, i) => (
<div className="apikey-card" key={k.id}>
<div className="apikey-card-header">
<div className="apikey-card-title">
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</div>
<div className="apikey-card-btns">
<button
className="btn btn--tonal btn--sq"
aria-label="Серверы ключа"
aria-expanded={tools === k.id}
onClick={() => setTools(tools === k.id ? null : k.id)}
><IcoSettings /></button>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<span className="apikey-card-servers">Серверы: {serversText(k)}</span>
{tools === k.id && toolbox(k)}
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
</div>
</div>
))}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">Ключ «{created.label}» создан</p>
<p className="qr-hint">
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
</div>
</div>
</div>
)}
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
/*
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.
*/
/* Стили вкладки «Аудит». Общая дизайн-система — в App.css. */
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.au-run { margin-left: auto; }
.au-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.au-notice {
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
border-radius: var(--shape-large); padding: 10px 16px; margin: 0;
}
.au-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; }
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.au-table td, .au-table th { white-space: nowrap; }
/* мобильный: таблица скрыта (.table-card), то же — карточками */
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.au-card .au-pub { max-width: 100%; }
@media (max-width: 839px) { .au-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } }
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
.au-run { margin-left: 0; }
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.au-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.au-setting-name { font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
.au-max { width: 88px !important; }
.au-select { width: auto !important; }
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.au-log .au-pub { max-width: 240px; }
+193
View File
@@ -0,0 +1,193 @@
// 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 } from 'react';
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../../lib/shared';
import { IcoRefresh, IcoTrash } from '../../../components/icons';
import { serverName } from '../../../components/ServerPicker';
import './audit.css';
// Раздел «Аудит» вкладки «Настройки»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
interface AuditEntry {
server_id: number; status: 'ok' | 'offline' | 'error'; ghosts_checked: boolean;
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number; blocked?: number;
}
interface Settings { clean: boolean; every_h: number; max: number }
interface Cleaned { at: number; server_id: number; pub_key: string; kind: 'ghost' | 'orphan'; key?: string | null; auto: boolean }
interface AuditData { enabled: boolean; settings: Settings; report: { at: number; servers: AuditEntry[] } | null; cleaned: Cleaned[] }
const PERIODS = [1, 3, 6, 12, 24];
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function AuditPage({ token, showMsg }: PageProps) {
const [audit, setAudit] = useState<AuditData | null>(null);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [busy, setBusy] = useState(false);
const [maxEdit, setMaxEdit] = useState('');
const load = useCallback(async () => {
try {
setAudit(await apiFetch('GET', '/ui/masterkeys/audit', token));
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch { showMsg('Ошибка загрузки аудита'); }
}, [token, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
const call = useCallback(async (method: string, path: string, ok: string, body?: object) => {
setBusy(true);
try { setAudit(await apiFetch(method, `/ui/masterkeys/audit${path}`, token, body)); showMsg(ok); }
catch (e) { showMsg(errText(e)); }
finally { setBusy(false); }
}, [token, showMsg]);
const run = () => call('POST', '', 'Аудит выполнен');
const save = (patch: Partial<Settings>) => call('PUT', '/settings', 'Настройки сохранены', patch);
if (!audit) return null;
if (!audit.enabled) return <p className="au-empty">Аудит выключен на этой панели: его проводит только core.</p>;
const { settings } = audit;
const servers = audit.report?.servers ?? [];
const dead = servers.reduce((n, e) => n + e.ghosts.length + e.orphans.length, 0);
const bad = servers.filter(e => e.status !== 'ok');
const confirmed = servers.reduce((n, e) => n + [...e.ghosts, ...e.orphans].filter(p => p.seen_before).length, 0);
const blocked = servers.filter(e => e.blocked);
const rows = servers.flatMap(e => [
...e.ghosts.map(p => ({ e, p, why: 'Есть в ядре, у awg-ctrl записи нет' })),
...e.orphans.map(p => ({ e, p, why: 'Панели не нужен, не снят' })),
]);
const state = (p: AuditPeer) => (
<span className={`chip chip--${p.online ? 'error' : 'offline'}`}>
<span className="chip-dot" />{p.online ? 'Онлайн' : p.lastHandshake ? `был ${timeAgo(p.lastHandshake)} назад` : 'не подключался'}
</span>
);
const confirm = (p: AuditPeer) => (
<span className="au-hint">{p.seen_before ? 'подтверждён' : 'найден впервые — снимется, если останется и в следующий раз'}</span>
);
return (
<>
<section className="au-head">
<span className={`chip chip--${!audit.report ? 'offline' : dead ? 'error' : 'online'}`}>
<span className="chip-dot" />
{!audit.report ? 'Аудит ещё не проводился' : dead ? `Мёртвых пиров: ${dead}` : 'Мёртвых пиров нет'}
</span>
{audit.report && (
<span className="au-hint">
Проверено {timeAgo(audit.report.at)} назад · серверов: {servers.length} · раз в {settings.every_h} ч
</span>
)}
<div className="au-btns">
{confirmed > 0 && (
<button className="btn btn--tonal" disabled={busy}
onClick={() => window.confirm(`Снять с серверов подтверждённых пиров: ${confirmed}?`) && call('POST', '/clean', 'Очистка выполнена')}>
<IcoTrash /> Снять подтверждённых ({confirmed})
</button>
)}
<button className="btn btn--tonal" disabled={busy} onClick={run}>
<IcoRefresh /> {busy ? 'Работаем…' : 'Проверить сейчас'}
</button>
</div>
</section>
<section className="au-box">
<h4 className="au-box-title">Параметры аудита</h4>
<label className="au-setting">
<span className="au-setting-text">
<span className="au-setting-name">Снимать мёртвых автоматически</span>
<span className="au-hint">Только тех, кто мёртв два прохода подряд. Ручной проход ничего не снимает.</span>
</span>
<input type="checkbox" checked={settings.clean} disabled={busy} onChange={e => save({ clean: e.target.checked })} />
</label>
<div className="au-setting">
<span className="au-setting-text">
<label className="au-setting-name" htmlFor="au-every">Период проверки</label>
<span className="au-hint">Как часто core обходит серверы</span>
</span>
<select id="au-every" className="field au-select" value={settings.every_h} disabled={busy}
onChange={e => save({ every_h: Number(e.target.value) })}>
{[...new Set([...PERIODS, settings.every_h])].sort((a, b) => a - b).map(h => <option key={h} value={h}>{h} ч</option>)}
</select>
</div>
<div className="au-setting">
<span className="au-setting-text">
<label className="au-setting-name" htmlFor="au-max">Предел за проход</label>
<span className="au-hint">Если на сервере мёртвых больше — автоочистка ничего не снимает и только сообщает (защита от сбоя)</span>
</span>
<span className="au-setting-ctl">
<input id="au-max" className="field au-max" type="number" min={1} max={1000}
value={maxEdit || String(settings.max)} onChange={e => setMaxEdit(e.target.value)} />
<button className="btn btn--tonal" disabled={busy || !maxEdit || Number(maxEdit) === settings.max}
onClick={() => save({ max: Number(maxEdit) }).then(() => setMaxEdit(''))}>Сохранить</button>
</span>
</div>
</section>
{bad.length > 0 && (
<p className="au-notice">
Не проверены: {bad.map(e => `${serverName(nodes, e.server_id)} (${e.status === 'offline' ? 'не в сети' : 'ошибка'})`).join(', ')}
</p>
)}
{blocked.length > 0 && (
<p className="au-notice">
Автоочистка пропустила: {blocked.map(e => `${serverName(nodes, e.server_id)} (${e.blocked} пиров при пределе ${settings.max})`).join(', ')}. Проверьте список и снимите вручную.
</p>
)}
{servers.some(e => e.status === 'ok' && !e.ghosts_checked) && (
<p className="au-notice">Часть серверов на старой версии awg-ctrl: пиров, которые есть только в ядре, там не видно.</p>
)}
{rows.length > 0 && (
<>
<div className="table-card au-table-card">
<table>
<thead><tr><th>Сервер</th><th>Публичный ключ</th><th>Причина</th><th>Мастер-ключ</th><th>Состояние</th></tr></thead>
<tbody>
{rows.map(({ e, p, why }) => (
<tr key={`${e.server_id}:${p.pub_key}`}>
<td>{serverName(nodes, e.server_id)}</td>
<td className="td-mono"><code className="au-pub" title={p.pub_key}>{p.pub_key}</code></td>
<td>{why}</td>
<td>{p.key ?? '—'}</td>
<td>{state(p)}<br />{confirm(p)}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="au-cards">
{rows.map(({ e, p, why }) => (
<li className="au-card" key={`${e.server_id}:${p.pub_key}`}>
<div className="au-card-top"><b>{serverName(nodes, e.server_id)}</b>{state(p)}</div>
<code className="au-pub" title={p.pub_key}>{p.pub_key}</code>
<span className="au-hint">{why}{p.key ? ` · ${p.key}` : ''}</span>
{confirm(p)}
</li>
))}
</ul>
</>
)}
{audit.cleaned.length > 0 && (
<section className="au-box">
<h4 className="au-box-title">Снято</h4>
<ul className="au-log">
{audit.cleaned.slice(0, 20).map(c => (
<li key={`${c.at}:${c.pub_key}`}>
<span className="au-hint">{timeAgo(c.at)} назад</span>
<span>{serverName(nodes, c.server_id)}</span>
<code className="au-pub" title={c.pub_key}>{c.pub_key}</code>
<span className="au-hint">{c.kind === 'ghost' ? 'был только в ядре' : 'лишний у awg-ctrl'}{c.key ? ` · ${c.key}` : ''} · {c.auto ? 'автоматически' : 'вручную'}</span>
</li>
))}
</ul>
</section>
)}
{audit.report && rows.length === 0 && <p className="au-empty">Всё чисто: лишних пиров на серверах нет.</p>}
</>
);
}
+285
View File
@@ -0,0 +1,285 @@
// 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, useLayoutEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { apiFetch, copyText, timeAgo, BRAND_FALLBACK, SETTINGS_SECTION_KEY, type Brand, type NodeInfo, type PageProps } from '../../lib/shared';
import { useTheme, setThemeMode, type ThemeMode } from '../../lib/theme';
import { IcoCheck, IcoCopy, IcoRefresh, IcoTrash, IcoPlus } from '../../components/icons';
import ApiKeysPage from './apikeys';
import AuditPage from './audit';
import MigratePage from './migrate';
import PrometheusInstall from './prometheus';
import ServerSettings from './server';
import './settings.css';
// Вкладка «Настройки»: настройки самой панели, не конкретного сервера (те — в меню серверов).
// Разделы — по вкладкам MD3 (primary tabs) сверху, виден один:
// Оформление — тема (хранится в браузере, lib/theme).
// Сервер — деление канала и блокировка торрентов сервера из шапки (./server; туда ведёт и шестерёнка
// в меню серверов).
// API-ключи, Аудит, Перенос — бывшие отдельные вкладки меню; живут в подпапках и
// рендерятся здесь целиком (реестр tabs/index.ts их не видит — он ищет на уровень выше).
// Prometheus — установка встроенного Prometheus (./prometheus) и токен «только метрики» для GET /metrics (/ui/metrics-token): выпуск,
// перевыпуск, отзыв. Значение показывается один раз, в БД — только хэш.
// О панели — имя, канал, версия и серверы.
interface MetricsToken {
exists: boolean;
prefix?: string;
created_at?: number;
last_used?: number | null;
per_peer: boolean; // пер-пировые серии (METRICS_PEERS=on в .env)
}
const SECTIONS = [
{ id: 'look', label: 'Оформление' },
{ id: 'server', label: 'Сервер' },
{ id: 'apikeys', label: 'API-ключи' },
{ id: 'prom', label: 'Prometheus' },
{ id: 'audit', label: 'Аудит' },
{ id: 'migrate', label: 'Перенос' },
{ id: 'about', label: 'О панели' },
] as const;
type SectionId = (typeof SECTIONS)[number]['id'];
// Открытый раздел помнится в браузере — чтобы «Аудит» или «Перенос» не искать заново
// (туда же пишет openTab из lib/shared, когда другая вкладка ведёт в нужный раздел)
const indexOf = (id: SectionId) => SECTIONS.findIndex(t => t.id === id);
function savedSection(): SectionId {
try {
const v = localStorage.getItem(SETTINGS_SECTION_KEY);
if (SECTIONS.some(t => t.id === v)) return v as SectionId;
} catch { /* не критично */ }
return 'look';
}
const THEMES: { id: ThemeMode; label: string }[] = [
{ id: 'light', label: 'Светлая' },
{ id: 'dark', label: 'Тёмная' },
{ id: 'system', label: 'Как в системе' },
];
export default function SettingsPage({ token, showMsg }: PageProps) {
const { mode } = useTheme();
const [metrics, setMetrics] = useState<MetricsToken | null>(null);
const [created, setCreated] = useState<string | null>(null); // открытый токен — один раз после выпуска
const [busy, setBusy] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [nodes, setNodes] = useState<NodeInfo[]>([]);
// dir — откуда въезжает раздел: 1 — справа (вкладка правее прежней), -1 — слева, 0 — первый показ
const [view, setView] = useState<{ section: SectionId; dir: number }>(() => ({ section: savedSection(), dir: 0 }));
const section = view.section;
const go = (id: SectionId) => setView(v => v.section === id ? v : { section: id, dir: Math.sign(indexOf(id) - indexOf(v.section)) });
const tabRefs = useRef<Partial<Record<SectionId, HTMLButtonElement | null>>>({});
const indicator = useRef<HTMLSpanElement>(null);
const centered = useRef(false); // первый показ — без анимации прокрутки вкладок
// Индикатор — одна полоса под активной вкладкой: переезжает к ней, а не появляется заново
useLayoutEffect(() => {
const place = () => {
const b = tabRefs.current[section], el = indicator.current;
if (b && el) { el.style.left = `${b.offsetLeft}px`; el.style.width = `${b.offsetWidth}px`; }
};
place();
// На узком экране вкладки прокручиваются — выбранную ставим по центру полосы (у краёв — насколько
// позволяет прокрутка). Двигаем саму полосу, а не scrollIntoView: тот прокрутил бы и страницу.
const b = tabRefs.current[section], bar = b?.parentElement;
if (b && bar && bar.scrollWidth > bar.clientWidth) {
const still = !centered.current || matchMedia('(prefers-reduced-motion: reduce)').matches;
bar.scrollTo({ left: b.offsetLeft - (bar.clientWidth - b.offsetWidth) / 2, behavior: still ? 'auto' : 'smooth' });
}
centered.current = true;
try { localStorage.setItem(SETTINGS_SECTION_KEY, section); } catch { /* не критично */ }
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
}, [section]);
// openTab('settings', раздел) при уже открытых настройках: раздел записан в localStorage — переключаемся на него
useEffect(() => {
const h = (e: Event) => { if ((e as CustomEvent).detail === 'settings') go(savedSection()); };
window.addEventListener('awg:open-tab', h);
return () => window.removeEventListener('awg:open-tab', h);
}, []);
// Стрелки, Home/End — по вкладкам (MD3 / WAI-ARIA tabs)
const onTabKey = (e: React.KeyboardEvent) => {
const i = indexOf(section);
const j = e.key === 'ArrowRight' ? (i + 1) % SECTIONS.length
: e.key === 'ArrowLeft' ? (i + SECTIONS.length - 1) % SECTIONS.length
: e.key === 'Home' ? 0 : e.key === 'End' ? SECTIONS.length - 1 : -1;
if (j < 0) return;
e.preventDefault();
go(SECTIONS[j].id);
tabRefs.current[SECTIONS[j].id]?.focus();
};
// Адрес /metrics рядом с панелью — с учётом префикса, под которым она открыта
const metricsUrl = new URL('metrics', location.href.split(/[?#]/)[0]).href;
const load = useCallback(async () => {
try {
setMetrics(await apiFetch('GET', '/ui/metrics-token', token));
} catch {
showMsg('Ошибка загрузки настроек');
}
axios.get('/ui/brand').then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK, channel: data.channel || '', version: data.version || '',
})).catch(() => {});
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
}, [token, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
const issue = useCallback(async () => {
if (metrics?.exists && !confirm('Перевыпустить токен? Старый сразу перестанет работать — Prometheus нужно будет перенастроить.')) return;
setBusy(true);
try {
const r = await apiFetch('POST', '/ui/metrics-token', token);
setCreated(r.token);
setMetrics(r);
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
} finally {
setBusy(false);
}
}, [metrics, token, showMsg]);
const revoke = useCallback(async () => {
if (!confirm('Отозвать токен? Prometheus перестанет получать метрики панели.')) return;
setBusy(true);
try {
setMetrics(await apiFetch('DELETE', '/ui/metrics-token', token));
showMsg('Токен отозван');
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
} finally {
setBusy(false);
}
}, [token, showMsg]);
const online = nodes.filter(n => n.online).length;
return (
<>
<div className="st-tabs" role="tablist" aria-label="Разделы настроек" onKeyDown={onTabKey}>
{SECTIONS.map(t => (
<button key={t.id} type="button" role="tab" id={`st-tab-${t.id}`} className="st-tab"
aria-selected={section === t.id} aria-controls={`st-panel-${t.id}`}
tabIndex={section === t.id ? 0 : -1}
ref={el => { tabRefs.current[t.id] = el; }} onClick={() => go(t.id)}>
{t.label}
</button>
))}
<span className="st-tabs-indicator" ref={indicator} aria-hidden="true" />
</div>
{/* Раздел пересоздаётся при смене (key) и въезжает со стороны выбранной вкладки */}
<div key={section} className="st-view" role="tabpanel" id={`st-panel-${section}`} aria-labelledby={`st-tab-${section}`}
style={{ '--st-dir': view.dir } as React.CSSProperties}>
{section === 'look' && (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Тема</span>
<span className="st-hint">Запоминается в этом браузере</span>
</span>
<div className="segmented" role="radiogroup" aria-label="Тема">
{THEMES.map(t => (
<button key={t.id} type="button" role="radio" aria-checked={mode === t.id}
className="segmented-item" onClick={() => setThemeMode(t.id)}>
{mode === t.id && <IcoCheck />}{t.label}
</button>
))}
</div>
</div>
</section>
)}
{section === 'prom' && <PrometheusInstall token={token} showMsg={showMsg} />}
{section === 'prom' && (
<section className="st-section">
<p className="st-hint st-lead">
Метрики панели и всех серверов отдаются по адресу <code className="st-code">{metricsUrl}</code> только
с токеном: Prometheus передаёт его заголовком <code className="st-code">Authorization: Bearer</code>.
Токен открывает только метрики — ни панель, ни API.
</p>
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Токен метрик</span>
{metrics?.exists ? (
<span className="st-hint">
<code className="st-code">{metrics.prefix}</code> · выпущен {new Date((metrics.created_at ?? 0) * 1000).toLocaleDateString()}
{' · '}{metrics.last_used ? `скрейп ${timeAgo(metrics.last_used)} назад` : 'ещё не использовался'}
</span>
) : (
<span className="st-hint">{metrics ? 'Не выпущен — /metrics закрыт' : 'Загрузка…'}</span>
)}
</span>
{metrics && (
<span className="st-row-ctl">
{metrics.exists && (
<button className="btn btn--danger" disabled={busy} onClick={revoke}><IcoTrash /> Отозвать</button>
)}
<button className={`btn ${metrics.exists ? 'btn--tonal' : 'btn--primary'}`} disabled={busy} onClick={issue}>
{metrics.exists ? <><IcoRefresh /> Перевыпустить</> : <><IcoPlus /> Выпустить</>}
</button>
</span>
)}
</div>
{metrics && (
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Метрики по пользователям</span>
<span className="st-hint">
{metrics.per_peer
? 'Включены: серии по каждому пиру с внутренним IP и именем'
: 'Выключены. Включаются строкой METRICS_PEERS=on в .env панели'}
</span>
</span>
<span className={`chip chip--${metrics.per_peer ? 'online' : 'offline'}`}>
<span className="chip-dot" />{metrics.per_peer ? 'Вкл' : 'Выкл'}
</span>
</div>
)}
</section>
)}
{section === 'server' && <ServerSettings token={token} showMsg={showMsg} />}
{section === 'apikeys' && <ApiKeysPage token={token} showMsg={showMsg} />}
{section === 'audit' && <AuditPage token={token} showMsg={showMsg} />}
{section === 'migrate' && <MigratePage token={token} showMsg={showMsg} />}
{section === 'about' && (
<section className="st-section">
<dl className="st-facts">
<div><dt>Панель</dt><dd>{brand.brand}</dd></div>
<div><dt>Версия</dt><dd>{[brand.channel, brand.version].filter(Boolean).join(' ') || '—'}</dd></div>
<div><dt>Серверы</dt><dd>{nodes.length ? `${nodes.length}, на связи ${online}` : '—'}</dd></div>
</dl>
</section>
)}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<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>.
</p>
<code className="st-secret">{created}</code>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
<button className="btn btn--primary" onClick={async () => {
await copyText(created);
showMsg('Токен скопирован');
}}><IcoCopy /> Скопировать</button>
</div>
</div>
</div>
)}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "settings",
"label": "Настройки",
"icon": "settings",
"group": "server",
"order": 9,
"chrome": {
"restart": false
}
}
+229
View File
@@ -0,0 +1,229 @@
// 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 } from 'react';
import { apiFetch, copyText, type PageProps } from '../../../lib/shared';
import { IcoCopy } from '../../../components/icons';
import './migrate.css';
// Раздел «Перенос» вкладки «Настройки»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
type MoveState = '' | 'sending' | 'moved' | 'uncertain' | 'imported';
// Новая панель после приёма: кто уже знает её адрес — тогда старый сервер можно выключать.
interface Retire {
since: number;
home: string | null; // адрес хаба для нод; null — не задан SUB_HOST
old: { exists: boolean; online: boolean };
devices: { total: number; fresh: number }; // fresh — получили конфиг после переноса
unused_links: number; // мастер-ключи без устройств: в ссылке старый адрес
nodes: { id: number; name: string; online: boolean; ok: boolean }[];
}
interface MigrateInfo {
state: MoveState;
error: string | null;
target: string | null; // старая: адрес новой панели
code: { code: string; expires_in: number } | null;
export?: string | null; // null — можно, строка — почему нельзя
accept?: string | null;
retire?: Retire | null;
}
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function MigratePage({ token, showMsg }: PageProps) {
const [info, setInfo] = useState<MigrateInfo | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
try { setInfo(await apiFetch('GET', '/ui/migrate', token)); }
catch { /* панель перезапускается — следующий опрос покажет */ }
}, [token]);
useEffect(() => { if (token) load(); }, [token, load]);
// Пока висит код — ждём, когда старая панель пришлёт архив; после приёма панель
// перезапускается, и страница перечитывается уже с перенесённой базой.
const waiting = !!info?.code || info?.state === 'imported' || !!info?.retire;
useEffect(() => {
if (!waiting) return;
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [waiting, load]);
useEffect(() => {
if (info?.state !== 'imported' || info.error) return;
const t = setTimeout(() => window.location.reload(), 8000);
return () => clearTimeout(t);
}, [info?.state, info?.error]);
const call = async (method: string, path: string, body?: object) => {
setBusy(true); setError('');
try { setInfo(await apiFetch(method, `/ui/migrate${path}`, token, body)); }
catch (e) { setError(errText(e)); }
finally { setBusy(false); }
};
const send = async () => {
if (!window.confirm('Перенести панель? Здесь она остановится, а этот сервер станет нодой новой панели.')) return;
setBusy(true); setError('');
try {
await apiFetch('POST', '/ui/migrate/export', token, { code: code.trim() });
await load();
} catch (e) { setError(errText(e)); await load(); }
finally { setBusy(false); }
};
if (!info) return null;
if (info.state === 'moved') return (
<section className="mv-box">
<span className="chip chip--online mv-self"><span className="chip-dot" />Панель перенесена</span>
<p className="mv-text">
Новая панель: <a href={info.target ?? '#'}>{info.target}</a>. Через несколько секунд этот сервер станет её
нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать,
а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.
</p>
{info.target && <a className="btn btn--primary mv-self" href={info.target}>Открыть новую панель</a>}
</section>
);
if (info.state === 'imported') return (
<section className="mv-box">
<span className={`chip chip--${info.error ? 'error' : 'online'} mv-self`}><span className="chip-dot" />
{info.error ? 'Приём прерван' : 'Панель принята'}</span>
<p className="mv-text">
{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}
</p>
</section>
);
const r = info.retire;
const retireBox = r && (() => {
const devicesOk = r.devices.fresh >= r.devices.total;
const nodesOk = r.nodes.every(n => n.ok);
const ready = devicesOk && nodesOk && !!r.home;
const chip = (ok: boolean, text: string, offline = false) => (
<span className={`chip chip--${ok ? 'online' : offline ? 'offline' : 'warm'}`}><span className="chip-dot" />{text}</span>
);
return (
<section className="mv-box" key="retire">
<h4 className="mv-title">Отключение старого сервера</h4>
<p className="mv-text">
{r.old.exists
? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его
можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними.</>
: <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.</>}
</p>
<div className="mv-checks">
<div className="mv-check">
{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)}
<span>устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}</span>
</div>
{r.nodes.map(n => (
<div className="mv-check" key={n.id}>
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)}
<span>нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}</span>
</div>
))}
{r.unused_links > 0 && (
<div className="mv-check">
{chip(false, String(r.unused_links), true)}
<span>мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на
вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же</span>
</div>
)}
</div>
{!r.home && <p className="mv-notice">Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.</p>}
<ol className="mv-steps">
<li>Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.</li>
<li>Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.</li>
<li>Удалите сервер «#0» и выключите старую машину.</li>
</ol>
{!r.old.exists && (
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('DELETE', '/retire')}>Готово, скрыть</button>
)}
</section>
);
})();
const canExport = info.export === null, canAccept = info.accept === null;
const exportBox = (
<section className="mv-box" key="export">
<h4 className="mv-title">Перенести эту панель на другой сервер</h4>
{canExport ? (
<>
<p className="mv-text">
Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели.
Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.
</p>
<ol className="mv-steps">
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
<li>Там в «Настройках», на вкладке «Перенос», нажмите «Получить код».</li>
<li>Вставьте код сюда и нажмите «Перенести».</li>
</ol>
{info.state === 'uncertain' ? (
<>
<p className="mv-notice">{info.error}</p>
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('POST', '/cancel')}>
Отменить перенос
</button>
</>
) : (
<div className="mv-row">
<input className="field" placeholder="awgmove://…" value={code} disabled={busy}
onChange={e => setCode(e.target.value)} />
<button className="btn btn--primary" disabled={busy || !code.trim().startsWith('awgmove://')} onClick={send}>
{busy ? 'Переносим…' : 'Перенести'}
</button>
</div>
)}
</>
) : <p className="mv-text mv-muted">{info.export}</p>}
</section>
);
const acceptBox = (
<section className="mv-box" key="accept">
<h4 className="mv-title">Принять панель с другого сервера</h4>
{canAccept ? (
info.code ? (
<>
<p className="mv-text">
Вставьте код в «Настройках» старой панели, на вкладке «Перенос». Он одноразовый и действует
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
</p>
<code className="node-modal-cmd">{info.code.code}</code>
<div className="mv-row mv-row--end">
<button className="btn btn--tonal" onClick={() => copyText(info.code!.code).then(() => showMsg('Код скопирован'))}>
<IcoCopy /> Скопировать
</button>
<button className="btn btn--outline" disabled={busy} onClick={() => call('DELETE', '/code')}>Отозвать</button>
</div>
<p className="mv-text mv-muted">Ждём архив от старой панели…</p>
</>
) : (
<>
<p className="mv-text">
Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней
нодой #0. Логин и пароль останутся этой панели.
</p>
<button className="btn btn--primary mv-self" disabled={busy}
onClick={() => call('POST', '/code', { host: window.location.hostname })}>
Получить код
</button>
</>
)
) : <p className="mv-text mv-muted">{info.accept}</p>}
</section>
);
return (
<>
{/* Доступное действие — первым. */}
{retireBox}
{canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]}
{error && <p className="mv-notice">{error}</p>}
</>
);
}
@@ -0,0 +1,25 @@
/*
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.
*/
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.mv-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
.mv-text { margin: 0; font-size: var(--body-medium-size); line-height: 1.5; }
.mv-muted { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: var(--body-medium-size); line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
border-radius: var(--shape-large); padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 599px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: var(--body-medium-size); line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
@@ -0,0 +1,141 @@
// 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, useRef } from 'react';
import { apiFetch, copyText, getServerId, announcePrometheus, type PageProps } from '../../../lib/shared';
import { IcoCopy, IcoPlus, IcoTrash } from '../../../components/icons';
import './prometheus.css';
// Установка встроенного Prometheus (раздел «Prometheus» вкладки «Настройки»; графики — во вкладке «Монитор»).
// Кнопка просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root; сама панель пакетов
// не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента — на сервер,
// выбранный в шапке (при смене сервера вкладка перемонтируется). У панели со своим VPN цель всегда
// «этот сервер».
export interface MonitorStatus {
ready: boolean; can_install: boolean; command: string;
host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
}
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
function JobProgress({ job, starting }: { job: MonitorStatus['job']; starting: boolean }) {
let step: [number, number, string] | null = null;
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
const failed = !!job && job.exit !== null && job.exit !== 0;
const done = !!job && job.exit === 0;
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
const label = failed ? `Не удалось (код ${job!.exit})`
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
return (
<div className="prom-progress" aria-live="polite">
<span className={`prom-pbar${failed ? ' prom-pbar--bad' : done ? ' prom-pbar--ok' : !step && (starting || job?.running) ? ' prom-pbar--wait' : ''}`}
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
<i style={{ width: `${pct}%` }} />
</span>
<span className="st-hint">{label}</span>
</div>
);
}
export default function PrometheusInstall({ token, showMsg }: PageProps) {
const target = getServerId() ?? 0; // куда ставить (панель без VPN) — сервер из шапки
const [status, setStatus] = useState<MonitorStatus | null>(null);
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена отсюда
const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', `/ui/monitor?server=${target}`, token) as MonitorStatus; announcePrometheus(s.ready); if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token, target]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог
useEffect(() => {
if (!pending && !status?.job?.running) return;
const t = setInterval(async () => {
const s = await loadStatus();
if (s && !s.job?.running) setPending(null);
}, 2000);
return () => clearInterval(t);
}, [pending, status?.job?.running, loadStatus]);
const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}?server=${target}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
if (!status) return <section className="st-section"><p className="st-hint">Загрузка…</p></section>;
const job = status.job;
const working = !!pending || !!job?.running;
const doing = pending ?? (job?.running ? job.action : null);
const showJob = !!pending || (!!job && (job.running || job.exit !== null));
const failed = !!job && job.exit !== null && job.exit !== 0;
// «Этот сервер» путал: в шапке может быть выбрана нода, а Prometheus у панели один и стоит не там
const where = status.host ? <>на сервере <b>{status.host.name}</b></> : 'на сервере панели';
const command = status.ready ? 'awg-ctrl monitor remove' : status.command;
return (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Встроенный Prometheus</span>
<span className="st-hint">
{status.ready
? <>Один на всю панель, собирает метрики всех серверов. Стоит {where}{status.host && ' (панель читает его через агента ноды)'}, слушает только 127.0.0.1:9090, графики хранит 31 день.</>
: <>Один на всю панель, соберёт метрики всех серверов. Панель поставит его {where} и сама подключит к своим метрикам: графики «Монитора» появятся через минуту-две. Наружу он не открывается.</>}
</span>
</span>
<span className="st-row-ctl">
<span className={`chip chip--${status.ready ? 'online' : 'offline'}`}>
<span className="chip-dot" />{doing === 'install' ? 'ставится' : doing === 'remove' ? 'удаляется' : status.ready ? 'работает' : 'не подключён'}
</span>
{status.can_install && (status.ready ? (
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
<IcoTrash /> {doing === 'remove' ? 'Удаляем…' : 'Удалить'}
</button>
) : (
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
<IcoPlus /> {doing === 'install' ? 'Устанавливаем…' : 'Установить'}
</button>
))}
</span>
</div>
{showJob && <JobProgress job={job} starting={!!pending} />}
{!status.can_install && (
<div className="st-row">
<span className="st-row-text">
<span className="st-hint">
{status.ready ? 'Удаляется на сервере с Prometheus от root:'
: 'На выбранном сервере установка кнопкой недоступна (нет связи или старый awg-ctrl): выберите другой сервер в шапке или выполните на этом от root:'}
</span>
<code className="prom-code">{command}</code>
</span>
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
</div>
)}
{/* Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt */}
{failed && (
<>
<pre className="prom-log">{job!.log.trim()}</pre>
<p className="login-error">
{job!.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${job!.action}`}</b>
</p>
</>
)}
</section>
);
}
@@ -0,0 +1,31 @@
/*
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.
*/
/* Установка Prometheus (раздел «Prometheus» вкладки «Настройки»). Строки — из settings.css. */
/* Ход установки или удаления: полоска + подпись шага */
.prom-progress { display: flex; flex-direction: column; gap: 6px; padding: 4px 0 12px; }
.prom-pbar { display: block; width: 100%; height: 8px; border-radius: var(--shape-extra-small); overflow: hidden; background: var(--primary-container); }
.prom-pbar i { display: block; height: 100%; border-radius: var(--shape-extra-small); background: var(--primary); transition: width var(--duration-medium4) var(--ease-standard); }
.prom-pbar--ok i { background: var(--success-dot); }
.prom-pbar--bad i { background: var(--error-dot); }
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
.prom-pbar--wait i { width: 35% !important; animation: prom-wait 1.2s ease-in-out infinite; }
@keyframes prom-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
@media (prefers-reduced-motion: reduce) { .prom-pbar--wait i { animation: none; } .prom-pbar i { transition: none; } }
.prom-code {
display: block; margin-top: 6px; word-break: break-all; user-select: all;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
background: var(--surface-container-lowest);
border-radius: var(--shape-medium);
padding: 10px 12px;
}
.prom-log {
margin: 0 0 8px; max-height: 240px; overflow: auto;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
background: var(--surface-container-lowest); border-radius: var(--shape-medium);
padding: 12px; white-space: pre-wrap; word-break: break-word;
}
+228
View File
@@ -0,0 +1,228 @@
// 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 { useEffect, useState } from 'react';
import axios from 'axios';
import { apiFetch, type PageProps, type ShapingStatus, type TorrentStatus } from '../../../lib/shared';
import './server.css';
// Раздел «Сервер» вкладки «Настройки»: деление канала между устройствами (CAKE) и блокировка BitTorrent
// для сервера из шапки (при его смене вкладка перемонтируется). Сами настройки живут в awg-ctrl этого
// сервера — запрос идёт обычным прокси с X-Server-Id, так что ядро и ноды настраиваются одинаково.
export default function ServerSettings({ token, showMsg }: PageProps) {
const [st, setSt] = useState<ShapingStatus | null>(null);
const [enabled, setEnabled] = useState(false);
const [auto, setAuto] = useState(true);
const [down, setDown] = useState('');
const [up, setUp] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [unsupported, setUnsupported] = useState(false);
const [tst, setTst] = useState<TorrentStatus | null>(null);
const [tEnabled, setTEnabled] = useState(false);
const [tUnsupported, setTUnsupported] = useState(false);
const fill = (s: ShapingStatus) => {
setSt(s);
setEnabled(s.settings.enabled); setAuto(s.settings.auto);
setDown(s.settings.downMbit ? String(s.settings.downMbit) : '');
setUp(s.settings.upMbit ? String(s.settings.upMbit) : '');
};
const errText = (e: unknown, fallback: string) =>
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
const fillTorrent = (s: TorrentStatus) => { setTst(s); setTEnabled(s.settings.enabled); };
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/torrent', token)
.then(s => { if (alive) fillTorrent(s as TorrentStatus); })
.catch(e => {
if (!alive) return;
if (axios.isAxiosError(e) && e.response?.status === 404) setTUnsupported(true);
else setError(errText(e, 'Не удалось загрузить настройки'));
});
return () => { alive = false; };
}, [token]);
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/shaping', token)
.then(s => { if (alive) fill(s as ShapingStatus); })
.catch(e => {
if (!alive) return;
// Старый awg-ctrl не знает этот маршрут — его надо обновить, а не «починить» здесь.
if (axios.isAxiosError(e) && e.response?.status === 404) setUnsupported(true);
else setError(errText(e, 'Не удалось загрузить настройки'));
});
return () => { alive = false; };
}, [token]);
const downN = Number(down), upN = Number(up);
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
// Отправляем только изменённое: лишний PUT /api/shaping заново ставил бы tc на интерфейс.
const shapingDirty = !!st && (enabled !== st.settings.enabled || auto !== st.settings.auto
|| (Number(down) || 0) !== st.settings.downMbit || (Number(up) || 0) !== st.settings.upMbit);
const torrentDirty = !!tst && tEnabled !== tst.settings.enabled;
const save = async () => {
setBusy(true); setError('');
const done: string[] = [];
try {
if (shapingDirty) {
const s = await apiFetch('PUT', '/api/shaping', token, {
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
});
fill(s as ShapingStatus);
done.push(enabled ? 'деление канала включено' : 'деление канала выключено');
}
if (torrentDirty) {
const s = await apiFetch('PUT', '/api/torrent', token, { enabled: tEnabled });
fillTorrent(s as TorrentStatus);
done.push(tEnabled ? 'торренты блокируются' : 'блокировка торрентов выключена');
}
if (done.length) showMsg(done.join(', ').replace(/^./, c => c.toUpperCase()));
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
finally { setBusy(false); }
};
return (
<>
<section className="st-section ss-card">
<h3 className="ss-section">Деление канала</h3>
{unsupported ? (
<p className="st-hint">
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
</p>
) : (
<>
<p className="st-hint">
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
</p>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Честное деление между устройствами</span>
</span>
<input type="checkbox" checked={enabled} disabled={!st || busy} onChange={e => setEnabled(e.target.checked)} />
</label>
<div className="ss-grid">
<label className="ss-field">
<span className="ss-setting-name">Загрузка, Мбит/с</span>
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
value={down} disabled={!st || busy} onChange={e => setDown(e.target.value)} />
</label>
<label className="ss-field">
<span className="ss-setting-name">Отдача, Мбит/с</span>
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
</label>
</div>
<p className="st-hint">
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
и деление перестаёт работать.
</p>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
<span className="st-hint">
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
</span>
</span>
<input type="checkbox" checked={auto} disabled={!st || busy || !enabled} onChange={e => setAuto(e.target.checked)} />
</label>
{st?.settings.enabled && (
<div className="ss-status">
<span className={`chip ${st.active ? (st.congested ? 'chip--warm' : 'chip--online') : 'chip--offline'}`}>
<span className="chip-dot" />
{!st.active ? 'не применено' : st.congested ? 'канал занят' : 'канал свободен'}
</span>
<span className="ss-mono">
↓ {st.rateDown} · ↑ {st.rateUp} Мбит/с · устройств {st.devices}
</span>
{st.heavy.length > 0 && (
<span className="ss-mono">в фоне: {st.heavy.join(', ')}</span>
)}
</div>
)}
</>
)}
</section>
<section className="st-section ss-card">
<h3 className="ss-section">Торренты</h3>
{tUnsupported ? (
<p className="st-hint">
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
</p>
) : (
<>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Блокировать BitTorrent</span>
<span className="st-hint">
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
трекеров большинство торрентов не стартуют. Остальной трафик не режется.
</span>
<span className="st-hint">
{enabled
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
</span>
</span>
<input type="checkbox" checked={tEnabled} disabled={!tst || busy} onChange={e => setTEnabled(e.target.checked)} />
</label>
{tst?.settings.enabled && (
<div className="ss-status">
<span className={`chip ${tst.active ? 'chip--online' : 'chip--offline'}`}>
<span className="chip-dot" />{tst.active ? 'работает' : 'не применено'}
</span>
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
{tst.active && !tst.tcp && (
<span className="st-hint">
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
</span>
)}
{tst.error && <span className="st-hint">{tst.error}</span>}
{tst.seen.length > 0 && (
<>
<span className="st-hint">Пытались качать за последние 10 минут:</span>
<span className="ss-seen">
{tst.seen.map(p => (
<span key={p.ip} className="chip chip--error">
<span className="chip-dot" />{p.name ? `${p.name} · ${p.ip}` : p.ip}
</span>
))}
</span>
</>
)}
</div>
)}
</>
)}
</section>
{error && <p className="login-error">{error}</p>}
{!(unsupported && tUnsupported) && (
<div className="ss-actions">
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
Сохранить
</button>
</div>
)}
</>
);
}
@@ -0,0 +1,11 @@
/*
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.
*/
/* Раздел «Сервер» вкладки «Настройки»: бывший диалог «Настройки сервера», строки .ss-* — из App.css */
.ss-card { gap: 12px; padding: 16px; }
.ss-card .ss-section { margin: 0; }
.ss-card .ss-status { background: var(--surface-container-lowest); }
.ss-actions { display: flex; justify-content: flex-end; }
.ss-card .ss-grid { max-width: 480px; }
+119
View File
@@ -0,0 +1,119 @@
/*
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.
*/
/* Стили вкладки «Настройки». Общая дизайн-система — в App.css (там же .segmented). */
/* Вкладки разделов (MD3 primary tabs, прокручиваемые): 48px, подпись title-small, прижаты влево;
активная — цветом primary и полосой 3px снизу, которая переезжает между вкладками (left/width
ставит компонент). Под вкладками — линия outline-variant во всю ширину. */
.st-tabs {
position: relative;
display: flex;
box-shadow: inset 0 -1px var(--outline-variant); /* линия — тенью: рамка ушла бы в прокрутку */
/* MD3 scrollable tabs: не влезли — прокручиваются вбок, без полосы прокрутки */
overflow-x: auto;
scrollbar-width: none;
}
.st-tabs::-webkit-scrollbar { display: none; }
.st-tab {
flex: 0 0 auto;
height: 48px;
padding: 0 20px;
border: none;
background: transparent;
color: var(--on-surface-variant);
font-family: var(--font);
font-size: var(--title-small-size); line-height: var(--title-small-line);
font-weight: var(--weight-medium);
white-space: nowrap;
cursor: pointer;
transition: color var(--duration-short3) var(--ease-standard);
}
.st-tab[aria-selected="true"] { color: var(--primary); }
.st-tab:focus-visible { outline-offset: -3px; }
.st-tabs-indicator {
position: absolute;
bottom: 0;
height: 3px;
border-radius: 3px 3px 0 0;
background: var(--primary);
transition: left var(--duration-medium2) var(--ease-standard), width var(--duration-medium2) var(--ease-standard);
}
@media (prefers-reduced-motion: reduce) { .st-tabs-indicator { transition: none; } }
/* Содержимое раздела — с тем же шагом, что у страницы. При смене вкладки въезжает на 32px со стороны
выбранной (--st-dir: 1 — справа, -1 — слева, 0 — только проявляется), как MD3 shared axis X.
Только backwards: после анимации transform не остаётся — иначе fixed-диалоги внутри (QR, ключи)
отсчитывались бы от раздела, а не от окна. */
.st-view {
display: flex; flex-direction: column; gap: 20px; min-width: 0;
animation: st-view-in var(--duration-medium2) var(--ease-emphasized-decelerate) backwards;
}
@keyframes st-view-in {
from { opacity: 0; transform: translateX(calc(var(--st-dir, 0) * 32px)); }
}
@media (prefers-reduced-motion: reduce) { .st-view { animation: none; } }
/* въезжающий раздел на миг шире страницы — без горизонтальной прокрутки */
.main:has(.st-view) { overflow-x: hidden; }
/* Раздел — блок на surface-container; строки внутри разделены линией outline-variant */
.st-section {
display: flex; flex-direction: column;
background: var(--surface-container);
border-radius: var(--shape-large);
padding: 12px 16px;
}
.st-lead { margin: 4px 0; }
.st-row {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 8px 16px;
padding: 12px 0;
}
.st-row + .st-row, .st-lead + .st-row { border-top: 1px solid var(--outline-variant); }
.st-row:first-child { padding-top: 4px; }
.st-row:last-child { padding-bottom: 4px; }
.st-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 240px; }
.st-row-name { font-size: var(--body-large-size); line-height: var(--body-large-line); color: var(--on-surface); }
.st-row-ctl { display: inline-flex; flex-wrap: wrap; gap: 8px; }
.st-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); overflow-wrap: anywhere; }
.st-code {
font-family: var(--mono);
font-size: 0.95em;
background: var(--surface-container-highest);
border-radius: var(--shape-extra-small);
padding: 0 4px;
overflow-wrap: anywhere;
}
/* О панели: пары «название — значение» */
.st-facts { display: flex; flex-direction: column; margin: 0; }
.st-facts > div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; }
.st-facts > div + div { border-top: 1px solid var(--outline-variant); }
.st-facts > div:first-child { padding-top: 4px; }
.st-facts > div:last-child { padding-bottom: 4px; }
.st-facts dt { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); }
.st-facts dd { margin: 0; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); text-align: right; }
/* Открытый токен (один раз после выпуска) — как ключ API */
.st-secret {
font-family: var(--mono);
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-surface);
background: var(--surface-container-lowest);
border-radius: var(--shape-medium);
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
/* compact: переключатель темы и кнопки — на всю ширину строки */
@media (max-width: 599px) {
.st-row > .segmented { width: 100%; }
.st-row > .segmented .segmented-item { flex: 1; padding: 0 8px; }
.st-row-ctl { width: 100%; }
.st-row-ctl .btn { flex: 1; }
}