diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md
index 803296a..8417e88 100644
--- a/docs/GATEWAY_RUN.md
+++ b/docs/GATEWAY_RUN.md
@@ -186,7 +186,7 @@ docker compose down
- **Nginx с `location /api/` и `proxy_pass http://…:9091/;` (со слэшем в конце)** на бэкенд уходит путь **без** префикса `/api/` (например запрос к nginx `GET /api/v1/users` превращается в `GET /v1/users` на Telemt). Шлюз при `base_url: https://gt2.example/api/` должен запрашивать именно **`/api/v1/…`** на стороне nginx. Если в `base_url` нет пути `/api/` (только `https://gt2.example`), шлюз обратится к `https://gt2.example/v1/…` — часто это **не** попадает в `location /api/`, и nginx отдаёт **чужой vhost / заглушку**. Задавайте `base_url` с завершающим слэшем: `https://gt2.example/api/`.
- **Заголовок `Host`**: шлюз выставляет `Host` равным хосту из `base_url` (как у обычного клиента к этому имени). Если после обновления образа проблема остаётся, с хоста шлюза проверьте: `curl -sv -o /dev/null https://gt2…/api/v1/health` и сравните с запросом через шлюз.
-- **`/v1/users` и `/v1/stats/users`** — разные маршруты в Telemt Control API. То, что открывается как `…/api/v1/stats/users` в браузере, через шлюз соответствует **`/api/{alias}/stats/users`**, а не `/api/{alias}/users`. См. матрицу путей в [API.md](API.md).
+- **Список пользователей через шлюз**: запрос **`GET` или `HEAD`** на **`/api/{alias}/users`** шлюз перенаправляет на upstream **`GET/HEAD /v1/stats/users`** (как и агрегатор). Так совместимы сборки Telemt, где прямой **`GET /v1/users`** даёт ошибку (например `400`), а **`/v1/stats/users`** работает. **`POST /api/{alias}/users`** (создание) и **`GET /api/{alias}/users/{username}`** по-прежнему идут на **`/v1/users`** и **`/v1/users/{username}`**. Явный путь **`/api/{alias}/stats/users`** не меняется. См. [API.md](API.md).
- **`docker pull`: `unauthorized` / `denied`**: выполните `docker login git.shts.su` с учётной записью Gitea и PAT с **`read:package`**.
- **`403 forbidden` с хоста при `allow_all: false`**: добавьте CIDR клиента в `whitelist_cidrs`. Запросы из контейнера к самому себе идут с `127.0.0.1` — при необходимости добавьте `127.0.0.1/32`.
- **За reverse proxy**: укажите CIDR прокси в `trusted_proxies`, иначе whitelist видит IP прокси, а не клиента.
diff --git a/internal/proxy/reverse.go b/internal/proxy/reverse.go
index 4c02553..65ceffb 100644
--- a/internal/proxy/reverse.go
+++ b/internal/proxy/reverse.go
@@ -9,7 +9,11 @@ import (
// NewReverseProxy builds a reverse proxy to target base URL with path rewriting:
// stripPrefix (/api/{alias}) + pathPrefix (/v1) + remainder, joined onto target via url.JoinPath
-// (e.g. https://host/api/ + v1 + users → https://host/api/v1/users).
+// (e.g. https://host/api/ + v1 + health → https://host/v1/health).
+//
+// Compatibility: GET/HEAD .../api/{alias}/users (list only, no extra path segment) is sent upstream as
+// /v1/stats/users. Some Telemt builds treat GET /v1/users incorrectly (e.g. 400) while /v1/stats/users works.
+// POST .../users (create) and GET .../users/{username} are unchanged.
func NewReverseProxy(target *url.URL, stripPrefix, pathPrefix string, setAuth string) *httputil.ReverseProxy {
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = DirectTransport()
@@ -30,6 +34,9 @@ func NewReverseProxy(target *url.URL, stripPrefix, pathPrefix string, setAuth st
}
rest := strings.TrimPrefix(p, stripPrefix)
rest = strings.TrimPrefix(rest, "/")
+ if (req.Method == http.MethodGet || req.Method == http.MethodHead) && rest == "users" {
+ rest = "stats/users"
+ }
joined := buildUpstreamURL(target, pathPrefix, rest)
req.URL.Scheme = joined.Scheme
req.URL.Host = joined.Host
diff --git a/internal/proxy/reverse_test.go b/internal/proxy/reverse_test.go
index f8eb33a..abab8ed 100644
--- a/internal/proxy/reverse_test.go
+++ b/internal/proxy/reverse_test.go
@@ -75,6 +75,81 @@ func TestDirectorRewritesPathWithAPIBasePath(t *testing.T) {
assertSameURL(t, cap.got.URL, want)
}
+func TestDirectorGETUsersListUsesStatsUsers(t *testing.T) {
+ target, err := url.Parse("http://127.0.0.1:9")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cap := &captureTransport{}
+ rp := NewReverseProxy(target, "/api/mtg", "/v1", "")
+ rp.Transport = cap
+
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/api/mtg/users", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rp.ServeHTTP(httptest.NewRecorder(), req)
+
+ if cap.got == nil {
+ t.Fatal("no outgoing request captured")
+ }
+ want, err := url.Parse("http://127.0.0.1:9/v1/stats/users")
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertSameURL(t, cap.got.URL, want)
+}
+
+func TestDirectorPOSTUsersCreateNotRewritten(t *testing.T) {
+ target, err := url.Parse("http://127.0.0.1:9")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cap := &captureTransport{}
+ rp := NewReverseProxy(target, "/api/mtg", "/v1", "")
+ rp.Transport = cap
+
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://127.0.0.1:9/api/mtg/users", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rp.ServeHTTP(httptest.NewRecorder(), req)
+
+ if cap.got == nil {
+ t.Fatal("no outgoing request captured")
+ }
+ want, err := url.Parse("http://127.0.0.1:9/v1/users")
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertSameURL(t, cap.got.URL, want)
+}
+
+func TestDirectorGETUsersByNameNotRewritten(t *testing.T) {
+ target, err := url.Parse("http://127.0.0.1:9")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cap := &captureTransport{}
+ rp := NewReverseProxy(target, "/api/mtg", "/v1", "")
+ rp.Transport = cap
+
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/api/mtg/users/alice", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rp.ServeHTTP(httptest.NewRecorder(), req)
+
+ if cap.got == nil {
+ t.Fatal("no outgoing request captured")
+ }
+ want, err := url.Parse("http://127.0.0.1:9/v1/users/alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertSameURL(t, cap.got.URL, want)
+}
+
func TestDirectorRewritesNestedStatsUsers(t *testing.T) {
target, err := url.Parse("http://127.0.0.1:9/api/")
if err != nil {
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
index a090089..c2247cc 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -47,8 +47,9 @@
fleet = f.data ?? null;
uniqueIps = u.data ?? null;
+ // Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
const aliases = (fleet?.servers ?? [])
- .filter((x) => x.alias && x.ok && x.health_ok && x.system_info_ok)
+ .filter((x) => x.alias && x.health_ok && x.system_info_ok)
.map((x) => x.alias as string);
const next: typeof nodeStats = {};
const concurrency = 4;
@@ -61,8 +62,8 @@
const d = st.data;
next[alias] = {
ok: true,
- bad: d.connections_bad_total,
- total: d.connections_total
+ bad: numStat(d.connections_bad_total, 0),
+ total: numStat(d.connections_total, 0)
};
} catch (e) {
next[alias] = {
@@ -83,6 +84,16 @@
onMount(load);
+ /** Число из ответа Telemt (поле может отсутствовать или прийти строкой). */
+ function numStat(v: unknown, fallback = 0): number {
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
+ if (typeof v === 'string' && v.trim() !== '') {
+ const n = Number(v);
+ if (Number.isFinite(n)) return n;
+ }
+ return fallback;
+ }
+
let totalBad = $derived.by(() => {
let n = 0;
for (const v of Object.values(nodeStats)) {
@@ -268,13 +279,13 @@
{#if srv.alias}
{@const st = nodeStats[srv.alias]}
{#if !st}
- —
+ —
{:else if 'ok' in st && st.ok === false}
- —
+ —
{:else if st && 'bad' in st && st.ok}
- {st.bad ?? '—'}
+ {st.bad ?? 0}
{:else}
- —
+ —
{/if}
{:else}
—