Prometheus on a node; panel under a URL prefix; open ports on migrate import

A panel without VPN (e.g. after the move) can use a Prometheus that lives on
a node, such as the one with history left on the old server (node #0). Both
directions go through the agent's WSS: the node's Prometheus scrapes
127.0.0.1:AGENT_METRICS_PORT/metrics, the agent checks its token and asks
core (metrics → metrics_res, the same exposition as /metrics, at most once
per 5 s per connection); the monitor tab reads it back through the agent,
which forwards only GET /-/ready and /api/v1/query[_range]. The panel finds
it by itself (local first, then the lowest-id node), and the install/remove
buttons work on a panel without VPN. `monitor install` on a node without its
own panel picks AGENT_METRICS_PORT, writes it to cli.env and restarts the
agent; on the old server it keeps the collected data.

The panel works behind nginx in a subfolder: relative asset and API paths,
relative directory redirects, and the client address from X-Forwarded-For /
X-Real-IP only from trusted proxies (loopback, TRUST_PROXY).

migrate import opens the moved SUB_PORT/NODE_PORT in UFW when it is active.
This commit is contained in:
2026-10-21 19:25:54 +03:00
parent 7ee126af30
commit 96c21408e6
20 changed files with 980 additions and 65 deletions
+2 -1
View File
@@ -118,7 +118,8 @@ app.get("/ui/monitor", (_q, r) => {
const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length;
job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") };
}
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", job });
// MOCK_ROLE=core/retire — панель без VPN: Prometheus на ноде #0 (сервер, с которого она переехала)
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", host: MOCK_CORE ? { id: 0, name: "VPN" } : null, job });
});
for (const action of ["install", "remove"] as const) {
app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); });
+9
View File
@@ -3,8 +3,17 @@
// LICENSE file in the root directory of this source tree.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import axios from 'axios';
import App from './App';
// Пути к API в коде пишутся от корня («/ui/nodes»), а уходят от адреса страницы («ui/nodes»):
// так панель за reverse-proxy в подпапке (example.com/forgetting/) ходит в свой префикс, а в корне
// всё как раньше. Одно место на все запросы, включая будущие.
axios.interceptors.request.use(c => {
if (c.url?.startsWith('/') && !c.url.startsWith('//')) c.url = c.url.slice(1);
return c;
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+14 -10
View File
@@ -8,11 +8,12 @@ import LineChart from '../../components/LineChart';
import './monitor.css';
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
// Кнопка «Подключить Prometheus» просит локальный awg-ctrl поставить его (`awg-ctrl monitor install`, root;
// сама панель пакетов не ставит). Графики читают Prometheus через фиксированные запросы панели
// (awg-ui/monitor.ts).
// Кнопка «Подключить Prometheus» просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root;
// сама панель пакетов не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента.
// Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go).
interface Status {
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; // последняя операция
}
type Pts = [number, number][];
@@ -93,7 +94,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', '/ui/monitor', token) as Status; if (alive.current) setStatus(s); return s; }
try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token]);
@@ -139,7 +140,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}`, token); await loadStatus(); }
try { await apiFetch('POST', `/ui/monitor/${action}?server=${getServerId() ?? 0}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
const working = !!pending || !!status?.job?.running;
@@ -174,7 +175,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span>
</div>
<p className="mon-hint">
Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики —
Панель поставит Prometheus {status.host ? <>на сервер <b>{status.host.name}</b></> : 'на этот сервер'} и сама подключит его к своим метрикам: основные графики —
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
только 127.0.0.1, наружу он не открывается.
</p>
@@ -188,7 +189,10 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):</p>
<p className="mon-hint">
У этой панели нет своего VPN-сервера: выберите ноду в сервер-баре, чтобы поставить Prometheus на неё
кнопкой, или выполните на ней от root:
</p>
<code className="mon-code">{status.command}</code>
<div className="mon-actions">
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
@@ -210,8 +214,8 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<span className="chip chip--online"><span className="chip-dot" />работает</span>
</div>
<p className="mon-hint">
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет
пакет и сотрёт накопленные данные.
Стоит {status.host ? <>на сервере <b>{status.host.name}</b> (панель читает его через агента ноды)</> : 'на этом сервере'} и
слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет пакет и сотрёт накопленные данные.
</p>
{status.can_install ? (
<div className="mon-actions">
@@ -222,7 +226,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере от root:</p>
<p className="mon-hint">Выполните на сервере с Prometheus от root:</p>
<code className="mon-code">awg-ctrl monitor remove</code>
</>
)}
+4
View File
@@ -5,6 +5,10 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
// Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в
// подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же
// (main.tsx).
base: './',
plugins: [react()],
server: {
proxy: {