From 91e214dad2ed096aa3870a94455f9b214283b0df Mon Sep 17 00:00:00 2001 From: nineap Date: Thu, 9 Jul 2026 23:20:44 +0700 Subject: [PATCH] =?UTF-8?q?=D0=95=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3:=20?= =?UTF-8?q?=D0=B1=D0=B8=D0=B7=D0=BD=D0=B5=D1=81-=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20+=20=D0=B2=D1=8B=D0=BD=D0=BE=D1=81=20Prome?= =?UTF-8?q?theus/Loki/Grafana=20=D0=BD=D0=B0=20VPS=20=D1=81=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prometheus/Loki/Grafana больше не живут внутри backend-стека — единый стек теперь на VPS с данными (netrunner-data), т.к. ему нужно видеть ещё лендинг и все прокси-ноды, не только бэкенд. Отсюда только тонкий promtail, пушащий логи по сети (LOKI_PUSH_URL). Новые бизнес-метрики поверх уже существующих generic HTTP-метрик: payments_total{provider,status}, nodes_online/nodes_total, support_tickets_total{status}, referral_signups_total, partner_commission_total{currency} — тем же макросом metrics::counter!/ gauge!, что уже был в api.rs/error.rs. Новый гейт-эндпоинт GET /api/v1/admin/observability/check (только Owner/Moderator) — для Caddy forward_auth перед Grafana (см. netrunner-data/observability/Caddyfile), встройка в админку через GRAFANA_PUBLIC_URL в GET /api/v1/config. templates/init_node.sh — добавляет --metrics-port 9093 в провижининг новых прокси-нод. Co-Authored-By: Claude Sonnet 5 --- .env.example | 19 ++++++++++- docker-compose.prod.yml | 60 +++++++-------------------------- grafana-datasources.yml | 17 ---------- loki-config.yml | 37 -------------------- prometheus.yml | 7 ---- promtail-config.yml | 6 +++- src/api.rs | 19 +++++++++-- src/modules/auth/service.rs | 1 + src/modules/billing/service.rs | 8 +++++ src/modules/staff/controller.rs | 38 ++++++++++++++++++++- src/tasks.rs | 9 +++++ templates/init_node.sh | 3 +- 12 files changed, 109 insertions(+), 115 deletions(-) delete mode 100644 grafana-datasources.yml delete mode 100644 loki-config.yml delete mode 100644 prometheus.yml diff --git a/.env.example b/.env.example index e13470a..904ca2c 100644 --- a/.env.example +++ b/.env.example @@ -32,13 +32,30 @@ REDIS_URL=redis://redis:6379 # ===================================================================== JWT_SECRET=CHANGE_ME_JWT_SECRET_openssl_rand_hex_32 ARGON_SALT=CHANGE_ME_ARGON_SALT_MIN_16_CHARS -GRAFANA_PASSWORD=CHANGE_ME_GRAFANA_PASSWORD TON_MASTER_WALLET=UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKK8y # Бэкапы Postgres (pg_dump + опциональный rclone) теперь настраиваются в # netrunner-data/.env (там же, где физически стоит БД) — см. # netrunner-data/scripts/backup.sh. Здесь их больше нет. +# ===================================================================== +# === НАБЛЮДАЕМОСТЬ === +# ===================================================================== +# Prometheus/Loki/Grafana теперь единым стеком на VPS с данными (см. +# netrunner-data/docker-compose.observability.yml) — GRAFANA_PASSWORD +# больше не нужен здесь. Этот бэкенд только пушит логи туда через тонкий +# promtail (сервис `promtail` в docker-compose.prod.yml) — публичный адрес +# того VPS, порт 3100 (обязательно зафайрволенный там на IP этого хоста). +LOKI_PUSH_URL=http://CHANGE_ME_DATA_VPS_IP:3100 + +# Публичный URL Grafana за auth-гейтом Caddy (см. +# netrunner-data/observability/Caddyfile) — отдаётся фронту через +# GET /api/v1/config для встройки в ObservabilityPage.tsx (frontend/src/). +# Не задан — страница просто не показывает дашборды. Ставить ТОЛЬКО после +# того, как в Caddyfile настоящий backend-URL вместо CHANGE_ME_BACKEND_PUBLIC_URL — +# иначе будет вести на нерабочий auth-гейт. +GRAFANA_PUBLIC_URL=https://CHANGE_ME_GRAFANA_DOMAIN + # Опционально: без него оплата через @CryptoBot вернёт "provider not # supported", остальные провайдеры продолжают работать. Токен — у @CryptoBot: # /pay -> Create App -> Payment Token. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 424a9dc..32872dd 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,67 +62,31 @@ services: - netrunner_grid # ========================================== - # МОНИТОРИНГ + # НАБЛЮДАЕМОСТЬ # ========================================== - prometheus: - image: prom/prometheus:latest - container_name: netrunner-prometheus - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - ports: - - "127.0.0.1:9090:9090" - restart: always - networks: - - netrunner_grid - - loki: - image: grafana/loki:latest - container_name: netrunner-loki - volumes: - - ./loki-config.yml:/etc/loki/local-config.yaml:ro - ports: - - "127.0.0.1:3100:3100" - command: -config.file=/etc/loki/local-config.yaml - restart: always - networks: - - netrunner_grid - + # Prometheus/Loki/Grafana больше НЕ здесь — единый стек теперь на VPS с + # данными (netrunner-data/docker-compose.observability.yml), т.к. ему + # всё равно нужно видеть лендинг и все прокси-ноды, не только бэкенд. + # /metrics бэкенда (см. src/api.rs) — тот central Prometheus скрейпит + # удалённо; отсюда только пуш логов через тонкий promtail. promtail: image: grafana/promtail:latest container_name: netrunner-promtail + environment: + - LOKI_PUSH_URL=${LOKI_PUSH_URL} volumes: - ./promtail-config.yml:/etc/promtail/config.yml:ro - /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/run/docker.sock:/var/run/docker.sock:ro - command: -config.file=/etc/promtail/config.yml + # -config.expand-env: без него promtail не подставит ${LOKI_PUSH_URL} + # внутри самого config.yml — сама переменная передаётся выше через + # environment, файл конфига читает её только с этим флагом. + command: -config.file=/etc/promtail/config.yml -config.expand-env=true restart: always user: root - depends_on: - - loki - networks: - - netrunner_grid - - grafana: - image: grafana/grafana:latest - container_name: netrunner-grafana - ports: - - "127.0.0.1:3030:3000" - environment: - - GF_SECURITY_ADMIN_USER=admin - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD} - volumes: - - grafana_data:/var/lib/grafana - - ./grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro - depends_on: - - prometheus - - loki - restart: always networks: - netrunner_grid networks: netrunner_grid: driver: bridge - -volumes: - grafana_data: diff --git a/grafana-datasources.yml b/grafana-datasources.yml deleted file mode 100644 index 45cd211..0000000 --- a/grafana-datasources.yml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: 1 - -datasources: - - name: Prometheus - type: prometheus - access: proxy - url: http://prometheus:9090 - isDefault: true - version: 1 - editable: true - - - name: Loki - type: loki - access: proxy - url: http://loki:3100 - version: 1 - editable: true diff --git a/loki-config.yml b/loki-config.yml deleted file mode 100644 index b0acfbb..0000000 --- a/loki-config.yml +++ /dev/null @@ -1,37 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - grpc_listen_port: 9096 - -common: - instance_addr: 127.0.0.1 - path_prefix: /tmp/loki - storage: - filesystem: - chunks_directory: /tmp/loki/chunks - rules_directory: /tmp/loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -query_range: - results_cache: - cache: - embedded_cache: - enabled: true - max_size_mb: 100 - -schema_config: - configs: - - from: 2020-10-24 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -ruler: - alertmanager_url: http://localhost:9093 diff --git a/prometheus.yml b/prometheus.yml deleted file mode 100644 index efafce5..0000000 --- a/prometheus.yml +++ /dev/null @@ -1,7 +0,0 @@ -global: - scrape_interval: 15s # Как часто опрашивать бэкенд - -scrape_configs: - - job_name: "netrunner-backend" - static_configs: - - targets: ["app:8080"] # Адрес твоего Rust-бэкенда diff --git a/promtail-config.yml b/promtail-config.yml index d12e863..18f5748 100644 --- a/promtail-config.yml +++ b/promtail-config.yml @@ -1,3 +1,7 @@ +# Тонкий promtail — шлёт логи ЭТОГО хоста (app/migrate/redis) в центральный +# Loki на VPS с данными (см. netrunner-data/docker-compose.observability.yml). +# LOKI_PUSH_URL — публичный адрес того VPS, порт 3100, обязательно +# зафайрволенный там на IP этого хоста (см. шапку docker-compose.observability.yml). server: http_listen_port: 9080 grpc_listen_port: 0 @@ -6,7 +10,7 @@ positions: filename: /tmp/positions.yaml clients: - - url: http://loki:3100/loki/api/v1/push + - url: ${LOKI_PUSH_URL}/loki/api/v1/push scrape_configs: - job_name: docker_service_logs diff --git a/src/api.rs b/src/api.rs index 6980411..a90bacb 100644 --- a/src/api.rs +++ b/src/api.rs @@ -32,6 +32,7 @@ use crate::modules::{ proxy_internal, referrals::{controller as ref_ctrl, service::ReferralService}, staff::{controller as staff_ctrl, service::StaffService}, + support::{controller as support_ctrl, service::SupportService}, users::{controller as user_ctrl, service::UserService}, }; @@ -43,6 +44,7 @@ pub struct ApiState { pub referral_service: ReferralService, pub user_service: UserService, pub staff_service: StaffService, + pub support_service: SupportService, pub jwt_secret: String, pub bot_token: String, /// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`. @@ -84,6 +86,7 @@ impl Clone for ApiState { referral_service: self.referral_service.clone(), user_service: self.user_service.clone(), staff_service: self.staff_service.clone(), + support_service: self.support_service.clone(), jwt_secret: self.jwt_secret.clone(), bot_token: self.bot_token.clone(), bot_username: self.bot_username.clone(), @@ -144,7 +147,12 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router { .nest("/staff", staff_ctrl::router(state.clone())) .nest("/staff", staff_ctrl::owner_router(state.clone())) .nest("/staff", staff_ctrl::staff_router(state.clone())) - .nest("/partners", staff_ctrl::partner_router(state.clone())); + .nest("/partners", staff_ctrl::partner_router(state.clone())) + .nest("/support", support_ctrl::router(state.clone())) + .nest( + "/observability", + staff_ctrl::observability_router(state.clone()), + ); // Внутренний контракт для прокси-нод — отдельный секрет, не auth_guard // (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда @@ -201,7 +209,14 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router { /// Публичная (без auth_guard) рантайм-конфигурация для статического SPA — /// см. `ApiState::bot_username` про то, почему это не build-time env фронта. async fn get_public_config(State(state): State) -> impl IntoResponse { - Json(json!({ "bot_username": state.bot_username })) + // Публичный URL Grafana (за auth-гейтом Caddy, см. + // netrunner-data/observability/Caddyfile) — тот же паттерн, что и + // bot_username выше: читается на сервере заново на каждый запрос, а не + // зашивается в SPA-бандл на этапе сборки, т.к. разный на разных стендах. + // `None`, пока GRAFANA_PUBLIC_URL не задан — ObservabilityPage.tsx в + // этом случае просто не показывает ссылку/iframe. + let grafana_url = std::env::var("GRAFANA_PUBLIC_URL").ok(); + Json(json!({ "bot_username": state.bot_username, "grafana_url": grafana_url })) } /// Liveness: процесс жив и отвечает. Не трогает зависимости. diff --git a/src/modules/auth/service.rs b/src/modules/auth/service.rs index 144683a..fbb772f 100644 --- a/src/modules/auth/service.rs +++ b/src/modules/auth/service.rs @@ -492,6 +492,7 @@ impl AuthService { .create_referral(referrer.id, new_user_id) .await .ok(); + metrics::counter!("referral_signups_total").increment(1); } Ok(()) } diff --git a/src/modules/billing/service.rs b/src/modules/billing/service.rs index 9c1affd..7ce120d 100644 --- a/src/modules/billing/service.rs +++ b/src/modules/billing/service.rs @@ -214,6 +214,8 @@ impl BillingService { .repo .create_invoice(user.id, plan_name, provider_name, currency, invoice_amount) .await?; + metrics::counter!("payments_total", "provider" => provider_name.to_string(), "status" => "initiated") + .increment(1); let invoice_data = InvoiceData { invoice_id, @@ -273,6 +275,8 @@ impl BillingService { self.repo .update_invoice_status(invoice_id, "failed", Some(external_tx_id)) .await?; + metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "failed") + .increment(1); return Err(AppError::BusinessLogic( "Payment verification failed".into(), )); @@ -282,6 +286,8 @@ impl BillingService { self.repo .update_invoice_status(invoice_id, "paid", Some(external_tx_id)) .await?; + metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "paid") + .increment(1); // 5. АКТИВИРУЕМ ТАРИФ self.repo @@ -360,6 +366,8 @@ impl BillingService { percent, ) .await?; + metrics::counter!("partner_commission_total", "currency" => invoice.currency.clone()) + .increment(commission as u64); } Ok(()) } diff --git a/src/modules/staff/controller.rs b/src/modules/staff/controller.rs index 7861ca5..baa68f9 100644 --- a/src/modules/staff/controller.rs +++ b/src/modules/staff/controller.rs @@ -4,6 +4,7 @@ use axum::{ extract::{Extension, Path, State}, + http::StatusCode, middleware, routing::{get, patch, post}, Json, Router, @@ -27,10 +28,24 @@ pub fn router(state: ApiState) -> Router { .route_layer(middleware::from_fn_with_state(state, auth_guard)) } -/// Только Owner: создание модераторов, выдача им права на управление нодами. +/// Только Owner/Moderator — гейт для Caddy `forward_auth` перед Grafana +/// (см. netrunner-data/observability/Caddyfile). Тело ответа не важно, важен +/// только статус: 200 — Caddy пускает дальше в Grafana, 401 — блокирует. +/// Бизнес-метрики (выручка, число юзеров) чувствительны — Support/Partner +/// сюда не допускаются, поэтому `staff_guard`, а не `support_access_guard`. +pub fn observability_router(state: ApiState) -> Router { + Router::new() + .route("/check", get(|| async { StatusCode::OK })) + .route_layer(middleware::from_fn_with_state(state.clone(), staff_guard)) + .route_layer(middleware::from_fn_with_state(state, auth_guard)) +} + +/// Только Owner: создание модераторов/саппортов, выдача модераторам права +/// на управление нодами. pub fn owner_router(state: ApiState) -> Router { Router::new() .route("/moderators", post(create_moderator)) + .route("/support", post(create_support)) .route("/users/{id}/permissions", patch(set_permissions)) .route_layer(middleware::from_fn_with_state(state.clone(), owner_guard)) .route_layer(middleware::from_fn_with_state(state, auth_guard)) @@ -89,6 +104,27 @@ async fn create_moderator( }))) } +#[derive(Deserialize)] +pub struct CreateSupportPayload { + pub username: String, + pub password: String, +} + +async fn create_support( + State(state): State, + Json(p): Json, +) -> Result, AppError> { + let user = state + .staff_service + .create_support(&p.username, &p.password) + .await?; + Ok(Json(json!({ + "id": user.id, + "username": user.username, + "role": user.role, + }))) +} + #[derive(Deserialize)] pub struct CreatePartnerPayload { pub username: String, diff --git a/src/tasks.rs b/src/tasks.rs index 12f5a96..ae9542c 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -82,6 +82,9 @@ async fn run_node_health_check(repo: &Arc) { } }; + let total = nodes.len(); + let mut online_count: u64 = 0; + for node in nodes { if node.status == "provisioning" { continue; @@ -102,6 +105,9 @@ async fn run_node_health_check(repo: &Arc) { }; let new_status = if is_reachable { "online" } else { "offline" }; + if is_reachable { + online_count += 1; + } if new_status != node.status { info!(node_id = %node.id, name = %node.name, from = %node.status, to = %new_status, "Смена статуса ноды по health-check"); } @@ -110,4 +116,7 @@ async fn run_node_health_check(repo: &Arc) { error!(node_id = %node.id, error = ?e, "Health-check: не удалось обновить статус ноды"); } } + + metrics::gauge!("nodes_online").set(online_count as f64); + metrics::gauge!("nodes_total").set(total as f64); } diff --git a/templates/init_node.sh b/templates/init_node.sh index b5fe135..0c9b877 100644 --- a/templates/init_node.sh +++ b/templates/init_node.sh @@ -40,7 +40,8 @@ docker run -d \ --decoy-host "$SNI_DOMAIN" \ --require-auth \ --backend-url "$BACKEND_URL" \ - --health-port 9091 + --health-port 9091 \ + --metrics-port 9093 echo "[*] Step 6: Starting Watchtower for auto-updates..." docker run -d \