diff --git a/DOCS_AI.md b/DOCS_AI.md index 00ac4b9..6afb1f4 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -453,6 +453,8 @@ switch_language ru|en Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users//enabled`, генерация proxy links, общий traffic history из `/opt/gotelegram/stats_history.csv`, per-user traffic history из `/opt/gotelegram/user_stats_history.csv` с периодами 15m/1h/24h/month, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`). +Traffic retention: обе CSV-истории хранятся максимум 365 дней. Последние 31 день остаются с поминутным разрешением, более старые точки автоматически уплотняются до одной последней cumulative-точки в час. Чистка/уплотнение запускается не чаще одного раза в час, а per-user сбор пишет данные не чаще одного раза в минуту, даже если systemd-loop вызывает `stats_collect` каждую секунду. Параметры можно переопределить переменными `STATS_RETENTION_DAYS`, `STATS_MINUTE_RETENTION_DAYS`, `STATS_CLEANUP_INTERVAL`. + ### 13.1.1 Shared TCP/443 with 3x-ui/Xray `lib/shared443.sh` добавляет управляемую схему shared-443 через nginx stream `ssl_preread`: diff --git a/DOCS_HUMAN.md b/DOCS_HUMAN.md index bd468a2..b80be41 100644 --- a/DOCS_HUMAN.md +++ b/DOCS_HUMAN.md @@ -147,6 +147,8 @@ CLI и бот переведены на русский и английский. В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, полезный блок «кто слушает порт 443» по данным `ss`, управление ключами `[access.users]` с добавлением, удалением и быстрым отключением через switch, генерация ссылок, traffic history по периодам 15 минут / 1 час / 24 часа / месяц с переключателем график/строки, такая же статистика по каждому ключу, кнопка разового обновления статистики, кнопка перезапуска сборщика, список бекапов и просмотр логов с количеством строк и статусом `journalctl`. +История трафика хранится максимум 1 год. Чтобы файлы не разрастались, последние 31 день пишутся поминутно, а более старая история автоматически уплотняется до одной точки в час. Для обычного просмотра 15 минут / 1 час / 24 часа / месяц детализация остаётся полной. + ## 8.1 3x-ui / VLESS на том же 443 Один порт `443` не могут одновременно слушать `telemt` и Xray напрямую. Для совместной работы используется схема shared-443: публичный `443` занимает nginx stream-диспетчер, goTelegram `telemt` переносится на `127.0.0.1:7443`, сайт остаётся на `127.0.0.1:8443`, а inbound 3x-ui/Xray нужно в панели перенести на внутренний адрес, например `127.0.0.1:9443`. diff --git a/admin-web/server.py b/admin-web/server.py index 25e6d11..8b4039f 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -691,9 +691,26 @@ def load_user_stats_history(name: str | None = None, limit: int | None = 240) -> def latest_user_stats() -> dict[str, dict[str, Any]]: latest: dict[str, dict[str, Any]] = {} - for row in load_user_stats_history(limit=None): - if row["epoch"] >= latest.get(row["user"], {}).get("epoch", 0): - latest[row["user"]] = row + if not USER_HISTORY_FILE.exists(): + return latest + try: + with USER_HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh: + for row in csv.DictReader(fh): + user = str(row.get("user") or "").strip() + if not USER_RE.match(user): + continue + item = { + "epoch": _int_value(row.get("epoch")), + "user": user, + "total_octets": _int_value(row.get("total_octets")), + "current_connections": _int_value(row.get("current_connections")), + "active_unique_ips": _int_value(row.get("active_unique_ips")), + "recent_unique_ips": _int_value(row.get("recent_unique_ips")), + } + if item["epoch"] >= latest.get(user, {}).get("epoch", 0): + latest[user] = item + except OSError: + return {} return latest diff --git a/lib/stats.sh b/lib/stats.sh index 07f6d43..2332d7d 100644 --- a/lib/stats.sh +++ b/lib/stats.sh @@ -17,6 +17,11 @@ SNAPSHOTS_DIR="$STATS_DIR/snapshots" CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json" CONFIG_FILE="/opt/gotelegram/config.json" TELEMT_CONFIG_FILE="/etc/telemt/config.toml" +STATS_RETENTION_DAYS="${STATS_RETENTION_DAYS:-365}" +STATS_MINUTE_RETENTION_DAYS="${STATS_MINUTE_RETENTION_DAYS:-31}" +STATS_CLEANUP_INTERVAL="${STATS_CLEANUP_INTERVAL:-3600}" +STATS_CLEANUP_STAMP="$STATS_DIR/last_history_cleanup" +USER_STATS_COLLECT_STAMP="$STATS_DIR/last_user_stats_minute" # Initialize stats infrastructure stats_init() { @@ -124,7 +129,7 @@ EOF if [[ "$last_ts" -eq 0 ]] || [[ $((current_minute - last_ts)) -ge 60 ]]; then echo "$current_minute,$proxy_bytes,$site_bytes" >> "$HISTORY_FILE" 2>/dev/null - # Cleanup old entries (keep only 365 days) + # Cleanup/compact history at most once per hour. stats_cleanup_history fi fi @@ -163,10 +168,17 @@ stats_collect_users() { command -v curl &>/dev/null || return 0 command -v jq &>/dev/null || return 0 + if [[ -f "$USER_STATS_COLLECT_STAMP" ]] && [[ "$(cat "$USER_STATS_COLLECT_STAMP" 2>/dev/null)" == "$current_minute" ]]; then + return 0 + fi + + local existing_users="" + existing_users=$(awk -F, -v ts="$current_minute" '$1 == ts { print $2 }' "$USER_HISTORY_FILE" 2>/dev/null || true) + local user payload total conns active_ips recent_ips while IFS= read -r user; do [[ -n "$user" ]] || continue - if awk -F, -v ts="$current_minute" -v user="$user" '$1 == ts && $2 == user { found=1 } END { exit found ? 0 : 1 }' "$USER_HISTORY_FILE" 2>/dev/null; then + if printf '%s\n' "$existing_users" | grep -Fxq "$user"; then continue fi @@ -183,6 +195,7 @@ stats_collect_users() { echo "$current_minute,$user,$total,$conns,$active_ips,$recent_ips" >> "$USER_HISTORY_FILE" 2>/dev/null done < <(stats_active_users) + echo "$current_minute" > "$USER_STATS_COLLECT_STAMP" 2>/dev/null || true stats_cleanup_user_history } @@ -348,20 +361,66 @@ show_traffic_stats() { } >&2 } -# Clean up history older than 365 days +_stats_positive_int() { + local value="${1:-0}" + [[ "$value" =~ ^[0-9]+$ ]] && [[ "$value" -gt 0 ]] && echo "$value" || echo "$2" +} + +stats_should_cleanup() { + local stamp="$1" + local now last interval + mkdir -p "$STATS_DIR" 2>/dev/null || true + now=$(date +%s) + interval=$(_stats_positive_int "$STATS_CLEANUP_INTERVAL" 3600) + last=$(cat "$stamp" 2>/dev/null || echo 0) + [[ "$last" =~ ^[0-9]+$ ]] || last=0 + if (( now - last < interval )); then + return 1 + fi + echo "$now" > "$stamp" 2>/dev/null || true + return 0 +} + +stats_retention_cutoffs() { + local now retention_days minute_days + now=$(date +%s) + retention_days=$(_stats_positive_int "$STATS_RETENTION_DAYS" 365) + minute_days=$(_stats_positive_int "$STATS_MINUTE_RETENTION_DAYS" 31) + if (( minute_days > retention_days )); then + minute_days="$retention_days" + fi + echo "$((now - retention_days * 86400)) $((now - minute_days * 86400))" +} + +# Keep history for at most one year. Recent points stay per-minute; older +# points are compacted to one last cumulative snapshot per hour. stats_cleanup_history() { if [[ ! -f "$HISTORY_FILE" ]]; then return fi - local now=$(date +%s) - local ts_365d=$((now - 31536000)) - local temp_file=$(mktemp) + stats_should_cleanup "$STATS_CLEANUP_STAMP" || return 0 + + local retention_cutoff minute_cutoff temp_file + read -r retention_cutoff minute_cutoff <<< "$(stats_retention_cutoffs)" + temp_file=$(mktemp) - # Keep header + entries from last 365 days { head -1 "$HISTORY_FILE" - awk -F, -v ts="$ts_365d" '$1 >= ts' "$HISTORY_FILE" | tail -n +2 + awk -F, -v keep="$retention_cutoff" -v minute="$minute_cutoff" ' + BEGIN { OFS="," } + NR == 1 { next } + $1 !~ /^[0-9]+$/ { next } + $1 < keep { next } + $1 >= minute { print $1, $2, $3; next } + { + bucket = int($1 / 3600) + compact[bucket] = $1 OFS $2 OFS $3 + } + END { + for (bucket in compact) print compact[bucket] + } + ' "$HISTORY_FILE" | sort -t, -k1,1n } > "$temp_file" 2>/dev/null mv "$temp_file" "$HISTORY_FILE" 2>/dev/null @@ -372,13 +431,28 @@ stats_cleanup_user_history() { return fi - local now=$(date +%s) - local ts_365d=$((now - 31536000)) - local temp_file=$(mktemp) + stats_should_cleanup "${STATS_CLEANUP_STAMP}.users" || return 0 + + local retention_cutoff minute_cutoff temp_file + read -r retention_cutoff minute_cutoff <<< "$(stats_retention_cutoffs)" + temp_file=$(mktemp) { head -1 "$USER_HISTORY_FILE" - awk -F, -v ts="$ts_365d" '$1 >= ts' "$USER_HISTORY_FILE" | tail -n +2 + awk -F, -v keep="$retention_cutoff" -v minute="$minute_cutoff" ' + BEGIN { OFS="," } + NR == 1 { next } + $1 !~ /^[0-9]+$/ { next } + $1 < keep { next } + $1 >= minute { print $1, $2, $3, $4, $5, $6; next } + { + bucket = $2 SUBSEP int($1 / 3600) + compact[bucket] = $1 OFS $2 OFS $3 OFS $4 OFS $5 OFS $6 + } + END { + for (bucket in compact) print compact[bucket] + } + ' "$USER_HISTORY_FILE" | sort -t, -k1,1n -k2,2 } > "$temp_file" 2>/dev/null mv "$temp_file" "$USER_HISTORY_FILE" 2>/dev/null @@ -516,5 +590,5 @@ remove_stats_collector() { # Export functions for external use export -f stats_init stats_collect stats_collect_users stats_active_users stats_read_current stats_calculate_rates export -f show_traffic_stats format_bytes format_rate toggle_stats -export -f stats_cleanup_history stats_cleanup_user_history install_stats_collector remove_stats_collector +export -f stats_cleanup_history stats_cleanup_user_history stats_should_cleanup stats_retention_cutoffs install_stats_collector remove_stats_collector export -f json_get