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:
+155
-39
@@ -2,10 +2,12 @@ package ui
|
||||
|
||||
// Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики.
|
||||
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
|
||||
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
|
||||
// POST /ui/monitor/install — попросить awg-ctrl выполнить `awg-ctrl monitor install` (root)
|
||||
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove`
|
||||
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч / 7 дн / 30 дн
|
||||
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
|
||||
// Prometheus — на этой машине или на ноде (панель без VPN, например после переноса): тогда и
|
||||
// запросы к нему, и его скрейп метрик панели идут через агента ноды по её WSS.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -80,8 +82,22 @@ type Monitor struct {
|
||||
promURL string
|
||||
localEnabled bool
|
||||
http *http.Client
|
||||
|
||||
srcMu sync.Mutex
|
||||
src *promSource // найденный Prometheus; nil — ещё не искали или не нашли
|
||||
srcAt time.Time
|
||||
}
|
||||
|
||||
// promSource — где Prometheus: local — на этой машине, иначе на ноде node (через её агента).
|
||||
type promSource struct {
|
||||
local bool
|
||||
node int
|
||||
}
|
||||
|
||||
// Найденный Prometheus помним недолго; ненайденный ищем при каждом запросе (вкладка
|
||||
// запрашивает статус редко, а во время установки должна увидеть его сразу).
|
||||
const promSourceTTL = 30 * time.Second
|
||||
|
||||
func newMonitor(a *App) *Monitor {
|
||||
return &Monitor{
|
||||
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
|
||||
@@ -92,14 +108,72 @@ func newMonitor(a *App) *Monitor {
|
||||
|
||||
var errProm = errors.New("Prometheus не отвечает")
|
||||
|
||||
func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, error) {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
q.Set("start", strconv.FormatInt(start, 10))
|
||||
q.Set("end", strconv.FormatInt(end, 10))
|
||||
q.Set("step", strconv.FormatInt(step, 10))
|
||||
// source — свой Prometheus, если он есть; иначе первый по id сервер, чей агент отдаёт свой.
|
||||
func (m *Monitor) source() *promSource {
|
||||
m.srcMu.Lock()
|
||||
if m.src != nil && time.Since(m.srcAt) < promSourceTTL {
|
||||
s := m.src
|
||||
m.srcMu.Unlock()
|
||||
return s
|
||||
}
|
||||
m.srcMu.Unlock()
|
||||
s := m.findSource()
|
||||
m.srcMu.Lock()
|
||||
m.src, m.srcAt = s, time.Now()
|
||||
m.srcMu.Unlock()
|
||||
return s
|
||||
}
|
||||
|
||||
// forgetSource — Prometheus поставили, удалили или он перестал отвечать: искать заново.
|
||||
func (m *Monitor) forgetSource() {
|
||||
m.srcMu.Lock()
|
||||
m.src = nil
|
||||
m.srcMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Monitor) findSource() *promSource {
|
||||
if m.promReady() {
|
||||
return &promSource{local: true}
|
||||
}
|
||||
ids := m.a.nodes.IDs()
|
||||
ready := make([]bool, len(ids))
|
||||
var wg sync.WaitGroup
|
||||
for i, id := range ids {
|
||||
// Сервер 0 со своим awg-ctrl — эта же машина: её Prometheus уже проверен выше.
|
||||
if id == 0 && m.localEnabled || !m.a.nodes.IsOnline(id) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, err := m.a.nodes.CtrlFor(id)("GET", "/prom/-/ready", nil)
|
||||
ready[i] = err == nil && r.Status == 200
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for i, id := range ids {
|
||||
if ready[i] {
|
||||
return &promSource{node: id}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// promGet — JSON-ответ Prometheus (path с запросом): напрямую или через агента ноды.
|
||||
func (m *Monitor) promGet(src *promSource, path string, q url.Values) (any, error) {
|
||||
if src == nil {
|
||||
return nil, errProm
|
||||
}
|
||||
path += "?" + q.Encode()
|
||||
if !src.local {
|
||||
r, err := m.a.nodes.CtrlFor(src.node)("GET", "/prom"+path, nil)
|
||||
if err != nil || r.Status < 200 || r.Status >= 300 {
|
||||
return nil, errProm
|
||||
}
|
||||
return r.Data, nil
|
||||
}
|
||||
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
|
||||
resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode())
|
||||
resp, err := c.Get(m.promURL + path)
|
||||
if err != nil {
|
||||
return nil, errProm
|
||||
}
|
||||
@@ -108,8 +182,21 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
|
||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, errProm
|
||||
}
|
||||
return decodeLikeAxios(raw), nil
|
||||
}
|
||||
|
||||
func (m *Monitor) rangeQuery(src *promSource, query string, start, end, step int64) (*series, error) {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
q.Set("start", strconv.FormatInt(start, 10))
|
||||
q.Set("end", strconv.FormatInt(end, 10))
|
||||
q.Set("step", strconv.FormatInt(step, 10))
|
||||
data, err := m.promGet(src, "/api/v1/query_range", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &series{m: map[int]*seriesOne{}}
|
||||
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
|
||||
for _, r := range arr(field(data, "data", "result")) {
|
||||
metric := field(r, "metric")
|
||||
idf := jsToNumberAny(field(metric, "server_id"))
|
||||
if field(metric, "server_id") == nil {
|
||||
@@ -146,22 +233,16 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
|
||||
}
|
||||
|
||||
// instantQuery — значение на момент time по каждому server_id.
|
||||
func (m *Monitor) instantQuery(query string, time_ int64) (map[int]float64, error) {
|
||||
func (m *Monitor) instantQuery(src *promSource, query string, time_ int64) (map[int]float64, error) {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
q.Set("time", strconv.FormatInt(time_, 10))
|
||||
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
|
||||
resp, err := c.Get(m.promURL + "/api/v1/query?" + q.Encode())
|
||||
data, err := m.promGet(src, "/api/v1/query", q)
|
||||
if err != nil {
|
||||
return nil, errProm
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, errProm
|
||||
return nil, err
|
||||
}
|
||||
out := map[int]float64{}
|
||||
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
|
||||
for _, r := range arr(field(data, "data", "result")) {
|
||||
metric := field(r, "metric")
|
||||
if field(metric, "server_id") == nil {
|
||||
continue
|
||||
@@ -228,12 +309,43 @@ type monitorJob struct {
|
||||
log string
|
||||
}
|
||||
|
||||
// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут.
|
||||
func (m *Monitor) jobState() monitorJob {
|
||||
if !m.localEnabled {
|
||||
// target — чей awg-ctrl ставит и удаляет Prometheus: того сервера, где он уже стоит; если его
|
||||
// нет — свой (панель + VPN) или выбранный в сервер-баре (панель без VPN). -1 — некому: например,
|
||||
// Prometheus на машине панели без VPN поставлен руками.
|
||||
func (m *Monitor) target(src *promSource, qs url.Values) int {
|
||||
switch {
|
||||
case src != nil && !src.local:
|
||||
return src.node
|
||||
case m.localEnabled:
|
||||
return 0
|
||||
case src != nil:
|
||||
return -1
|
||||
}
|
||||
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) && f >= 0 && m.a.nodes.Exists(int(f)) {
|
||||
return int(f)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// host — сервер target, если это не машина панели (её вкладка так и называет: «этот сервер»).
|
||||
func (m *Monitor) host(target int) any {
|
||||
if target < 0 || target == 0 && m.localEnabled {
|
||||
return nil
|
||||
}
|
||||
for _, s := range m.a.nodes.List() {
|
||||
if s.ID == target {
|
||||
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jobState — установка возможна, только если у target есть awg-ctrl и он знает маршрут.
|
||||
func (m *Monitor) jobState(target int) monitorJob {
|
||||
if target < 0 {
|
||||
return monitorJob{}
|
||||
}
|
||||
r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil)
|
||||
r, err := m.a.nodes.CtrlFor(target)("GET", "/api/monitor/job", nil)
|
||||
if err != nil || r.Status != 200 {
|
||||
return monitorJob{}
|
||||
}
|
||||
@@ -251,27 +363,26 @@ func (m *Monitor) jobState() monitorJob {
|
||||
func (m *Monitor) router() *Router {
|
||||
rt := newRouter()
|
||||
rt.Get("/", func(c *Ctx) bool {
|
||||
var ready bool
|
||||
var job monitorJob
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); ready = m.promReady() }()
|
||||
go func() { defer wg.Done(); job = m.jobState() }()
|
||||
wg.Wait()
|
||||
src := m.source()
|
||||
ready := src != nil
|
||||
target := m.target(src, c.R.URL.Query())
|
||||
job := m.jobState(target)
|
||||
var jobOut any
|
||||
if job.can && truthy(job.action) {
|
||||
jobOut = jsjson.Object{{Key: "action", Val: job.action}, {Key: "running", Val: job.running}, {Key: "exit", Val: job.exit}, {Key: "log", Val: job.log}}
|
||||
}
|
||||
return c.JSON(200, jsjson.Object{{Key: "ready", Val: ready}, {Key: "can_install", Val: job.can}, {Key: "job", Val: jobOut},
|
||||
{Key: "command", Val: "awg-ctrl monitor install"}})
|
||||
{Key: "command", Val: "awg-ctrl monitor install"}, {Key: "host", Val: m.host(target)}})
|
||||
})
|
||||
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
|
||||
// Установка и удаление — одно и то же: просим awg-ctrl сервера, он запускает CLI от root.
|
||||
ask := func(action string) handler {
|
||||
return func(c *Ctx) bool {
|
||||
if !m.localEnabled {
|
||||
return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине")
|
||||
target := m.target(m.source(), c.R.URL.Query())
|
||||
if target < 0 {
|
||||
return c.Error(409, "Некому поставить Prometheus: выберите VPN-сервер или выполните команду на машине")
|
||||
}
|
||||
r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil)
|
||||
m.forgetSource()
|
||||
r, err := m.a.nodes.CtrlFor(target)("POST", "/api/monitor/"+action, nil)
|
||||
if err != nil {
|
||||
return c.Error(502, "awg-ctrl недоступен")
|
||||
}
|
||||
@@ -294,6 +405,7 @@ func (m *Monitor) router() *Router {
|
||||
if !ok {
|
||||
return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d")
|
||||
}
|
||||
src := m.source()
|
||||
end := now()
|
||||
start := end - rg.seconds
|
||||
window := promRateWindow
|
||||
@@ -310,18 +422,21 @@ func (m *Monitor) router() *Router {
|
||||
var wg sync.WaitGroup
|
||||
for i, pq := range promQueries {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(withWindow(pq.q, window), start, end, rg.step) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
got[i], errs[i] = m.rangeQuery(src, withWindow(pq.q, window), start, end, rg.step)
|
||||
}()
|
||||
}
|
||||
span := strconv.FormatInt(rg.seconds, 10) + "s"
|
||||
n := len(promQueries)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
usedDown, errs[n] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
|
||||
usedDown, errs[n] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
usedUp, errs[n+1] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
|
||||
usedUp, errs[n+1] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
|
||||
}()
|
||||
if window != promRateWindow {
|
||||
for j, k := range rated {
|
||||
@@ -332,7 +447,7 @@ func (m *Monitor) router() *Router {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v, err := m.instantQuery(pq.q, end)
|
||||
v, err := m.instantQuery(src, pq.q, end)
|
||||
errs[n+2+j] = err
|
||||
mu.Lock()
|
||||
nowVals[k] = v
|
||||
@@ -344,6 +459,7 @@ func (m *Monitor) router() *Router {
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
m.forgetSource()
|
||||
return c.Error(502, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user