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,468 @@
|
||||
package ui
|
||||
|
||||
// Prometheus: GET /metrics на ядре (порт awg-ui/metrics.ts). Метрики ядра и всех нод
|
||||
// собираются здесь же — у нод через уже открытый WSS. Доступ — отдельный токен «только чтение
|
||||
// метрик»; он не открывает ни /api/v1, ни панель.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
const (
|
||||
scrapeDeadline = 5 * time.Second
|
||||
touchEveryS = 60 // last_used пишем не чаще раза в минуту, а не на каждый скрейп
|
||||
)
|
||||
|
||||
func hashToken(t string) []byte {
|
||||
s := sha256.Sum256([]byte(t))
|
||||
return s[:]
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
a *App
|
||||
perPeer bool
|
||||
localFile string
|
||||
|
||||
mu sync.Mutex
|
||||
localMod time.Time
|
||||
localSeen bool
|
||||
localHash []byte
|
||||
}
|
||||
|
||||
func newMetrics(a *App) (*Metrics, error) {
|
||||
m := &Metrics{
|
||||
a: a,
|
||||
// Пер-пировые серии (peer = внутренний IP, name = имя пользователя) выключены: это и
|
||||
// приватность, и кардинальность. Включается явно.
|
||||
perPeer: strings.ToLower(envOr("METRICS_PEERS", "off")) == "on",
|
||||
localFile: envOr("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"),
|
||||
}
|
||||
_, err := a.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS metrics_token (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
token_hash BLOB NOT NULL,
|
||||
prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER
|
||||
)
|
||||
`)
|
||||
return m, err
|
||||
}
|
||||
|
||||
var reHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// localTokenHash — токен встроенного Prometheus (`awg-ctrl monitor install`): здесь его хэш.
|
||||
// Файл перечитывается по mtime: установка не требует перезапуска панели.
|
||||
func (m *Metrics) localTokenHash() []byte {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
st, err := os.Stat(m.localFile)
|
||||
if err != nil {
|
||||
m.localSeen, m.localHash = false, nil
|
||||
return nil
|
||||
}
|
||||
if !m.localSeen || !st.ModTime().Equal(m.localMod) {
|
||||
raw, err := os.ReadFile(m.localFile)
|
||||
if err != nil {
|
||||
m.localSeen, m.localHash = false, nil
|
||||
return nil
|
||||
}
|
||||
m.localSeen, m.localMod, m.localHash = true, st.ModTime(), nil
|
||||
if h := strings.TrimSpace(string(raw)); reHex64.MatchString(h) {
|
||||
m.localHash, _ = hex.DecodeString(h)
|
||||
}
|
||||
}
|
||||
return m.localHash
|
||||
}
|
||||
|
||||
type tokenRow struct {
|
||||
hash []byte
|
||||
prefix string
|
||||
createdAt int64
|
||||
lastUsed sql.NullInt64
|
||||
}
|
||||
|
||||
func (m *Metrics) token() *tokenRow {
|
||||
t := &tokenRow{}
|
||||
if m.a.db.QueryRow("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&t.hash, &t.prefix, &t.createdAt, &t.lastUsed) != nil {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Metrics) view() jsjson.Object {
|
||||
if t := m.token(); t != nil {
|
||||
return jsjson.Object{{Key: "exists", Val: true}, {Key: "prefix", Val: t.prefix}, {Key: "created_at", Val: t.createdAt},
|
||||
{Key: "last_used", Val: nullInt(t.lastUsed)}, {Key: "per_peer", Val: m.perPeer}}
|
||||
}
|
||||
return jsjson.Object{{Key: "exists", Val: false}, {Key: "per_peer", Val: m.perPeer}}
|
||||
}
|
||||
|
||||
// router — управление токеном (роуты панели, за JWT). Выпуск и перевыпуск — одно и то же:
|
||||
// старый токен перестаёт работать. Значение отдаётся один раз.
|
||||
func (m *Metrics) router() *Router {
|
||||
rt := newRouter()
|
||||
rt.Get("/", func(c *Ctx) bool { return c.JSON(200, m.view()) })
|
||||
rt.Post("/", func(c *Ctx) bool {
|
||||
b := make([]byte, 24)
|
||||
rand.Read(b)
|
||||
tok := "awgm_" + b64url.EncodeToString(b)
|
||||
m.a.db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)",
|
||||
hashToken(tok), tok[:13]+"…", now())
|
||||
return c.JSON(201, append(m.view(), jsjson.KV{Key: "token", Val: tok}))
|
||||
})
|
||||
rt.Delete("/", func(c *Ctx) bool {
|
||||
m.a.db.Exec("DELETE FROM metrics_token WHERE id = 1")
|
||||
return c.JSON(200, m.view())
|
||||
})
|
||||
return rt
|
||||
}
|
||||
|
||||
func writeText(c *Ctx, status int, ctype, body string) bool {
|
||||
c.W.Header().Set("Content-Type", ctype)
|
||||
c.W.WriteHeader(status)
|
||||
c.W.Write([]byte(body))
|
||||
return false
|
||||
}
|
||||
|
||||
// requireToken — нет токена или он неверный — одинаковый 401: не подсказываем, выключены
|
||||
// метрики или токен не тот.
|
||||
func (m *Metrics) requireToken(c *Ctx) bool {
|
||||
given := bearer(c.R)
|
||||
row := m.token()
|
||||
var gh []byte
|
||||
if strings.HasPrefix(given, "awgm_") {
|
||||
gh = hashToken(given)
|
||||
}
|
||||
panelOK := row != nil && gh != nil && len(row.hash) == len(gh) && subtle.ConstantTimeCompare(gh, row.hash) == 1
|
||||
local := m.localTokenHash()
|
||||
localOK := gh != nil && local != nil && subtle.ConstantTimeCompare(gh, local) == 1
|
||||
if !panelOK && !localOK {
|
||||
c.W.Header().Set("WWW-Authenticate", "Bearer")
|
||||
return writeText(c, 401, "text/plain; charset=utf-8", "unauthorized\n")
|
||||
}
|
||||
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
|
||||
if t := now(); panelOK && (!row.lastUsed.Valid || row.lastUsed.Int64 == 0 || t-row.lastUsed.Int64 >= touchEveryS) {
|
||||
m.a.db.Exec("UPDATE metrics_token SET last_used = ? WHERE id = 1", t)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Экспозиция ──────────────────────────────────────────────────────────────
|
||||
|
||||
type label struct {
|
||||
k string
|
||||
v any
|
||||
}
|
||||
|
||||
type sample struct {
|
||||
labels []label
|
||||
v any // число; не число и не конечное — 0
|
||||
}
|
||||
|
||||
type exposition struct {
|
||||
b bytes.Buffer
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
var (
|
||||
escLabel = strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
|
||||
escHelp = strings.NewReplacer(`\`, `\\`, "\n", `\n`)
|
||||
)
|
||||
|
||||
// labelString — String(v) для значения метки.
|
||||
func labelString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case int:
|
||||
return jsjson.Number(float64(x))
|
||||
case int64:
|
||||
return jsjson.Number(float64(x))
|
||||
case float64:
|
||||
return jsjson.Number(x)
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case nil:
|
||||
return "undefined"
|
||||
}
|
||||
return "[object Object]"
|
||||
}
|
||||
|
||||
func (e *exposition) add(name, typ, help string, samples []sample) {
|
||||
if len(samples) == 0 {
|
||||
return
|
||||
}
|
||||
if !e.seen[name] {
|
||||
e.seen[name] = true
|
||||
e.b.WriteString("# HELP " + name + " " + escHelp.Replace(help) + "\n# TYPE " + name + " " + typ + "\n")
|
||||
}
|
||||
for _, s := range samples {
|
||||
e.b.WriteString(name)
|
||||
if len(s.labels) > 0 {
|
||||
parts := make([]string, len(s.labels))
|
||||
for i, l := range s.labels {
|
||||
parts[i] = l.k + `="` + escLabel.Replace(labelString(l.v)) + `"`
|
||||
}
|
||||
e.b.WriteString("{" + strings.Join(parts, ",") + "}")
|
||||
}
|
||||
v := 0.0
|
||||
switch x := s.v.(type) {
|
||||
case float64:
|
||||
v = x
|
||||
case int:
|
||||
v = float64(x)
|
||||
case int64:
|
||||
v = float64(x)
|
||||
}
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
v = 0
|
||||
}
|
||||
e.b.WriteString(" " + jsjson.Number(v) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func b2f(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// scrapeServer — один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
|
||||
func (m *Metrics) scrapeServer(s serverInfo) any {
|
||||
if s.Pending || !m.a.nodes.IsOnline(s.ID) {
|
||||
return nil
|
||||
}
|
||||
ch := make(chan any, 1)
|
||||
go func() {
|
||||
r, err := m.a.nodes.CtrlFor(s.ID)("GET", "/api/metrics", nil)
|
||||
if err != nil || r.Status != 200 {
|
||||
ch <- nil
|
||||
return
|
||||
}
|
||||
ch <- r.Data
|
||||
}()
|
||||
select {
|
||||
case d := <-ch:
|
||||
return d
|
||||
case <-time.After(scrapeDeadline):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) render() string {
|
||||
t0 := time.Now()
|
||||
list := m.a.nodes.List()
|
||||
data := make([]any, len(list))
|
||||
var wg sync.WaitGroup
|
||||
for i, s := range list {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); data[i] = m.scrapeServer(s) }()
|
||||
}
|
||||
wg.Wait()
|
||||
out := &exposition{seen: map[string]bool{}}
|
||||
L := func(s serverInfo, extra ...label) []label {
|
||||
return append([]label{{"server", s.Name}, {"server_id", s.ID}}, extra...)
|
||||
}
|
||||
// Серии только тех серверов, чьи метрики получены.
|
||||
per := func(fn func(s serverInfo, d any) []sample) []sample {
|
||||
var res []sample
|
||||
for i, s := range list {
|
||||
if truthy(data[i]) {
|
||||
res = append(res, fn(s, data[i])...)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
one := func(fn func(d any) any) func(serverInfo, any) []sample {
|
||||
return func(s serverInfo, d any) []sample { return []sample{{L(s), fn(d)}} }
|
||||
}
|
||||
or0 := func(v any) any {
|
||||
if v == nil {
|
||||
return 0.0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", []sample{{[]label{{"version", m.a.version}}, 1.0}})
|
||||
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", []sample{{nil, float64(len(list))}})
|
||||
var online, scrapeOK, info []sample
|
||||
for i, s := range list {
|
||||
online = append(online, sample{L(s), b2f(s.Online)})
|
||||
scrapeOK = append(scrapeOK, sample{L(s), b2f(truthy(data[i]))})
|
||||
if !s.Pending {
|
||||
gen, module, tools := any(""), any(""), any("")
|
||||
if s.Health != nil {
|
||||
gen, module, tools = s.Health.Gen, s.Health.Module, s.Health.Tools
|
||||
}
|
||||
ver := s.Version
|
||||
if ver == nil {
|
||||
ver = ""
|
||||
}
|
||||
info = append(info, sample{L(s, label{"version", ver}, label{"gen", gen}, label{"module", module}, label{"tools", tools}), 1.0})
|
||||
}
|
||||
}
|
||||
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", online)
|
||||
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", scrapeOK)
|
||||
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.", info)
|
||||
|
||||
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per(one(func(d any) any { return b2f(truthy(field(d, "up"))) })))
|
||||
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per(one(func(d any) any { return or0(field(d, "peers", "total")) })))
|
||||
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per(one(func(d any) any { return or0(field(d, "peers", "online")) })))
|
||||
// Сумма по пирам — gauge, а не counter: при удалении пира сумма уменьшается.
|
||||
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "rx_bytes")) })))
|
||||
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "tx_bytes")) })))
|
||||
for _, x := range [][3]string{
|
||||
{"rx_bytes", "receive_bytes", "байт"}, {"tx_bytes", "transmit_bytes", "байт"},
|
||||
{"rx_packets", "receive_packets", "пакетов"}, {"tx_packets", "transmit_packets", "пакетов"},
|
||||
{"rx_dropped", "receive_dropped", "потерянных пакетов"}, {"tx_dropped", "transmit_dropped", "потерянных пакетов"},
|
||||
{"rx_errors", "receive_errors", "ошибок"}, {"tx_errors", "transmit_errors", "ошибок"},
|
||||
} {
|
||||
out.add("awg_link_"+x[1]+"_total", "counter", "Интерфейс awg1: "+x[2]+" ("+x[0]+").", per(one(func(d any) any { return or0(field(d, "link", x[0])) })))
|
||||
}
|
||||
|
||||
// Нагрузка хоста. CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
|
||||
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
|
||||
per(func(s serverInfo, d any) []sample {
|
||||
cpu, _ := field(d, "host", "cpu").(jsjson.Object)
|
||||
var res []sample
|
||||
for _, kv := range cpu {
|
||||
res = append(res, sample{L(s, label{"mode", kv.Key}), jsToNumberAny(kv.Val)})
|
||||
}
|
||||
return res
|
||||
}))
|
||||
hostOne := func(fn func(h any) any) func(serverInfo, any) []sample {
|
||||
return func(s serverInfo, d any) []sample {
|
||||
h := field(d, "host")
|
||||
if !truthy(h) {
|
||||
return nil
|
||||
}
|
||||
return []sample{{L(s), fn(h)}}
|
||||
}
|
||||
}
|
||||
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per(hostOne(func(h any) any { return or0(field(h, "cores")) })))
|
||||
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "total")) })))
|
||||
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "available")) })))
|
||||
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).", per(func(s serverInfo, d any) []sample {
|
||||
h := field(d, "host")
|
||||
if !truthy(h) {
|
||||
return nil
|
||||
}
|
||||
load := arr(field(h, "load"))
|
||||
var res []sample
|
||||
for i, p := range []string{"1m", "5m", "15m"} {
|
||||
var v any = 0.0
|
||||
if i < len(load) && load[i] != nil {
|
||||
v = load[i]
|
||||
}
|
||||
res = append(res, sample{L(s, label{"period", p}), v})
|
||||
}
|
||||
return res
|
||||
}))
|
||||
|
||||
// Деление канала (CAKE)
|
||||
sh := func(fn func(x any) any) []sample {
|
||||
return per(func(s serverInfo, d any) []sample {
|
||||
x := field(d, "shaping")
|
||||
if !truthy(x) {
|
||||
return nil
|
||||
}
|
||||
return []sample{{L(s), fn(x)}}
|
||||
})
|
||||
}
|
||||
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "settings", "enabled"))) }))
|
||||
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "active"))) }))
|
||||
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "congested"))) }))
|
||||
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(func(x any) any {
|
||||
if h, ok := field(x, "heavy").([]any); ok {
|
||||
return float64(len(h))
|
||||
}
|
||||
return 0.0
|
||||
}))
|
||||
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(func(x any) any { return or0(field(x, "devices")) }))
|
||||
// dirs — значение nil (undefined) серию не даёт.
|
||||
dirs := func(fn func(x any, dir string) any) []sample {
|
||||
return per(func(s serverInfo, d any) []sample {
|
||||
x := field(d, "shaping")
|
||||
if !truthy(x) {
|
||||
return nil
|
||||
}
|
||||
var res []sample
|
||||
for _, dir := range []string{"down", "up"} {
|
||||
if v := fn(x, dir); v != nil {
|
||||
res = append(res, sample{L(s, label{"direction", dir}), v})
|
||||
}
|
||||
}
|
||||
return res
|
||||
})
|
||||
}
|
||||
mbit := func(v any) any {
|
||||
if v == nil {
|
||||
return math.NaN() // undefined * 1e6
|
||||
}
|
||||
return jsToNumberAny(v) * 1e6
|
||||
}
|
||||
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs(func(x any, dir string) any {
|
||||
if dir == "down" {
|
||||
return mbit(field(x, "settings", "downMbit"))
|
||||
}
|
||||
return mbit(field(x, "settings", "upMbit"))
|
||||
}))
|
||||
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs(func(x any, dir string) any {
|
||||
if dir == "down" {
|
||||
return mbit(field(x, "rateDown"))
|
||||
}
|
||||
return mbit(field(x, "rateUp"))
|
||||
}))
|
||||
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "drops") }))
|
||||
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "backlog") }))
|
||||
|
||||
if m.perPeer {
|
||||
peers := func(fn func(p any) any) []sample {
|
||||
return per(func(s serverInfo, d any) []sample {
|
||||
var res []sample
|
||||
for _, p := range arr(field(d, "list")) {
|
||||
name := field(p, "name")
|
||||
if name == nil {
|
||||
name = ""
|
||||
}
|
||||
res = append(res, sample{L(s, label{"peer", field(p, "ip")}, label{"name", name}, label{"kind", field(p, "kind")}), fn(p)})
|
||||
}
|
||||
return res
|
||||
})
|
||||
}
|
||||
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "online"))) }))
|
||||
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(func(p any) any { return b2f(field(p, "priority") == "background") }))
|
||||
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(func(p any) any { return field(p, "rx") }))
|
||||
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(func(p any) any { return field(p, "tx") }))
|
||||
}
|
||||
|
||||
var masters, devices int64
|
||||
m.a.db.QueryRow("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").Scan(&masters)
|
||||
m.a.db.QueryRow("SELECT COUNT(*) AS n FROM devices").Scan(&devices)
|
||||
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", []sample{{nil, masters}})
|
||||
out.add("awg_devices", "gauge", "Устройств sen://.", []sample{{nil, devices}})
|
||||
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", []sample{{nil, time.Since(t0).Seconds()}})
|
||||
return out.b.String()
|
||||
}
|
||||
|
||||
func (m *Metrics) handler(c *Ctx) bool {
|
||||
return writeText(c, 200, "text/plain; version=0.0.4; charset=utf-8", m.render())
|
||||
}
|
||||
Reference in New Issue
Block a user