Port awg-ui to Go: awg-control ui
internal/ui reimplements the awg-ui backend with the same HTTP surface; the React frontend is unchanged and served from awg-ui/dist. - web.go: Express-like router (express.json semantics, mounts, :params, HTML 400/404/413 pages, SPA fallback) - server.go: JWT login with rate limit, API keys and their server sets, awg-ctrl client and proxy, /api/v1/users, delete hooks - nodes.go: WSS hub for agents (pin, challenge-response, join strings), RPC with timeouts, health, /ui/nodes - sub.go, audit.go, sub_admin.go: sen:// master keys and devices, per-server reconcile with tombstones and /api/peers/sync, signed /sub/v1 listener, audit with two-pass confirmation and cleaning, panel and /api/v1/masterkeys routers - metrics.go, monitor.go: /metrics exposition with tokens, /ui/monitor - sen.go: link codec and protocol signatures, tested byte for byte against fixtures produced by awg-ui/sen.ts (tools/golden/sen.ts) Differential bench (tools/diffbench/local-ui.sh, scenario-ui.ts, compare-ui.mjs): 158 steps over every route group, including signed device requests and a Go agent joining as a node, plus the resulting ui.db and awg-ctrl state. TS and Go are identical after masking random values, across repeated runs and with the race detector on.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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/remove — то же для `awg-ctrl monitor remove`
|
||||
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч
|
||||
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
type promRange struct{ seconds, step int64 }
|
||||
|
||||
// Шаг — чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
|
||||
var promRanges = map[string]promRange{"1h": {3600, 15}, "6h": {21600, 60}, "24h": {86400, 300}}
|
||||
|
||||
// Запросы по серверам. Трафик — со счётчиков интерфейса (монотонны), а не с сумм по пирам.
|
||||
var promQueries = []struct{ key, q string }{
|
||||
{"down", `sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8`},
|
||||
{"up", `sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8`},
|
||||
{"online", `sum by (server_id, server) (awg_peers_online)`},
|
||||
{"heavy", `sum by (server_id, server) (awg_shaping_heavy_devices)`},
|
||||
{"drops", `sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))`},
|
||||
{"congested", `max by (server_id, server) (awg_shaping_congested)`},
|
||||
{"scrape_ok", `max by (server_id, server) (awg_scrape_ok)`},
|
||||
{"cpu", `100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))`},
|
||||
{"mem", `100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))`},
|
||||
{"cores", `max by (server_id, server) (awg_host_cpu_cores)`},
|
||||
{"mem_total", `max by (server_id, server) (awg_host_memory_total_bytes)`},
|
||||
}
|
||||
|
||||
type point [2]float64
|
||||
|
||||
type seriesOne struct {
|
||||
name any
|
||||
points []point
|
||||
}
|
||||
|
||||
// series — server_id → ряд, в порядке ответа Prometheus (как Map в JS).
|
||||
type series struct {
|
||||
ids []int
|
||||
m map[int]*seriesOne
|
||||
}
|
||||
|
||||
func (s *series) get(id int) *seriesOne {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.m[id]
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
a *App
|
||||
promURL string
|
||||
localEnabled bool
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newMonitor(a *App) *Monitor {
|
||||
return &Monitor{
|
||||
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
|
||||
localEnabled: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off",
|
||||
http: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
|
||||
resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode())
|
||||
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
|
||||
}
|
||||
out := &series{m: map[int]*seriesOne{}}
|
||||
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
|
||||
metric := field(r, "metric")
|
||||
idf := jsToNumberAny(field(metric, "server_id"))
|
||||
if field(metric, "server_id") == nil {
|
||||
idf = math.NaN()
|
||||
}
|
||||
if idf != float64(int(idf)) {
|
||||
continue
|
||||
}
|
||||
id := int(idf)
|
||||
name := field(metric, "server")
|
||||
if name == nil {
|
||||
name = strconv.Itoa(id)
|
||||
}
|
||||
s := &seriesOne{name: name}
|
||||
for _, v := range arr(field(r, "values")) {
|
||||
pair := arr(v)
|
||||
if len(pair) < 2 {
|
||||
continue
|
||||
}
|
||||
t := jsToNumberAny(pair[0])
|
||||
val := jsToNumberAny(pair[1])
|
||||
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
|
||||
if math.IsNaN(val) || math.IsInf(val, 0) {
|
||||
continue
|
||||
}
|
||||
s.points = append(s.points, point{t, val})
|
||||
}
|
||||
if _, dup := out.m[id]; !dup {
|
||||
out.ids = append(out.ids, id)
|
||||
}
|
||||
out.m[id] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// total — сумма (или максимум) рядов по меткам времени.
|
||||
func total(s *series, max bool) []any {
|
||||
acc := map[float64]float64{}
|
||||
var order []float64
|
||||
for _, id := range s.ids {
|
||||
for _, p := range s.m[id].points {
|
||||
cur, seen := acc[p[0]]
|
||||
if !seen {
|
||||
order = append(order, p[0])
|
||||
}
|
||||
if max {
|
||||
acc[p[0]] = math.Max(cur, p[1])
|
||||
} else {
|
||||
acc[p[0]] = cur + p[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Float64s(order)
|
||||
out := []any{}
|
||||
for _, t := range order {
|
||||
out = append(out, []any{t, acc[t]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func last(s *seriesOne) any {
|
||||
if s == nil || len(s.points) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.points[len(s.points)-1][1]
|
||||
}
|
||||
|
||||
func (m *Monitor) promReady() bool {
|
||||
c := &http.Client{Transport: m.http.Transport, Timeout: 1500 * time.Millisecond}
|
||||
resp, err := c.Get(m.promURL + "/-/ready")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
type monitorJob struct {
|
||||
can bool
|
||||
action any
|
||||
running bool
|
||||
exit any
|
||||
log string
|
||||
}
|
||||
|
||||
// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут.
|
||||
func (m *Monitor) jobState() monitorJob {
|
||||
if !m.localEnabled {
|
||||
return monitorJob{}
|
||||
}
|
||||
r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil)
|
||||
if err != nil || r.Status != 200 {
|
||||
return monitorJob{}
|
||||
}
|
||||
j := monitorJob{can: true, action: field(r.Data, "action"), running: truthy(field(r.Data, "running")), exit: field(r.Data, "exit")}
|
||||
switch l := field(r.Data, "log").(type) {
|
||||
case nil:
|
||||
case string:
|
||||
j.log = l
|
||||
default:
|
||||
j.log, _ = jsString(l)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
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()
|
||||
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"}})
|
||||
})
|
||||
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
|
||||
ask := func(action string) handler {
|
||||
return func(c *Ctx) bool {
|
||||
if !m.localEnabled {
|
||||
return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине")
|
||||
}
|
||||
r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil)
|
||||
if err != nil {
|
||||
return c.Error(502, "awg-ctrl недоступен")
|
||||
}
|
||||
if r.Status == 404 {
|
||||
return c.Error(501, "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную")
|
||||
}
|
||||
return c.JSON(r.Status, r.Data)
|
||||
}
|
||||
}
|
||||
rt.Post("/install", ask("install"))
|
||||
rt.Post("/remove", ask("remove"))
|
||||
|
||||
rt.Get("/overview", func(c *Ctx) bool {
|
||||
qs := c.R.URL.Query()
|
||||
rangeKey := "1h"
|
||||
if _, ok := qs["range"]; ok {
|
||||
rangeKey = strings.Join(qs["range"], ",")
|
||||
}
|
||||
rg, ok := promRanges[rangeKey]
|
||||
if !ok {
|
||||
return c.Error(400, "range: 1h, 6h или 24h")
|
||||
}
|
||||
end := now()
|
||||
start := end - rg.seconds
|
||||
got := make([]*series, len(promQueries))
|
||||
errs := make([]error, len(promQueries))
|
||||
var wg sync.WaitGroup
|
||||
for i, pq := range promQueries {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(pq.q, start, end, rg.step) }()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return c.Error(502, err.Error())
|
||||
}
|
||||
}
|
||||
// Вкладка показывает один сервер — выбранный в сервер-баре (?server=<id>).
|
||||
if _, has := qs["server"]; has {
|
||||
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) {
|
||||
only := int(f)
|
||||
for i, s := range got {
|
||||
f := &series{m: map[int]*seriesOne{}}
|
||||
if x, ok := s.m[only]; ok {
|
||||
f.ids, f.m[only] = []int{only}, x
|
||||
}
|
||||
got[i] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
s := map[string]*series{}
|
||||
for i, pq := range promQueries {
|
||||
s[pq.key] = got[i]
|
||||
}
|
||||
ids := map[int]bool{}
|
||||
for _, x := range got {
|
||||
for _, id := range x.ids {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
var sorted []int
|
||||
for id := range ids {
|
||||
sorted = append(sorted, id)
|
||||
}
|
||||
sort.Ints(sorted)
|
||||
servers := []any{}
|
||||
for _, id := range sorted {
|
||||
var name any = strconv.Itoa(id)
|
||||
for _, k := range []string{"scrape_ok", "down", "online"} {
|
||||
if x := s[k].get(id); x != nil {
|
||||
name = x.name
|
||||
break
|
||||
}
|
||||
}
|
||||
servers = append(servers, jsjson.Object{
|
||||
{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "ok", Val: last(s["scrape_ok"].get(id)) == 1.0},
|
||||
{Key: "down", Val: last(s["down"].get(id))}, {Key: "up", Val: last(s["up"].get(id))},
|
||||
{Key: "online", Val: last(s["online"].get(id))}, {Key: "heavy", Val: last(s["heavy"].get(id))},
|
||||
{Key: "congested", Val: last(s["congested"].get(id)) == 1.0}, {Key: "cpu", Val: last(s["cpu"].get(id))},
|
||||
{Key: "mem", Val: last(s["mem"].get(id))}, {Key: "cores", Val: last(s["cores"].get(id))},
|
||||
{Key: "mem_total", Val: last(s["mem_total"].get(id))},
|
||||
})
|
||||
}
|
||||
var rangeOut any = "1h"
|
||||
if _, ok := qs["range"]; ok {
|
||||
rangeOut = rangeKey
|
||||
}
|
||||
return c.JSON(200, jsjson.Object{
|
||||
{Key: "range", Val: rangeOut}, {Key: "step", Val: rg.step}, {Key: "from", Val: start}, {Key: "to", Val: end},
|
||||
{Key: "servers", Val: servers},
|
||||
{Key: "total", Val: jsjson.Object{
|
||||
{Key: "down", Val: total(s["down"], false)}, {Key: "up", Val: total(s["up"], false)}, {Key: "online", Val: total(s["online"], false)},
|
||||
{Key: "heavy", Val: total(s["heavy"], false)}, {Key: "drops", Val: total(s["drops"], false)},
|
||||
{Key: "cpu", Val: total(s["cpu"], true)}, {Key: "mem", Val: total(s["mem"], true)},
|
||||
}},
|
||||
})
|
||||
})
|
||||
return rt
|
||||
}
|
||||
Reference in New Issue
Block a user