v2.5.0: compact traffic history retention

This commit is contained in:
Виталий Литвинов
2026-04-25 14:15:28 +03:00
parent 63b564f70f
commit c7540a97f7
4 changed files with 111 additions and 16 deletions

View File

@@ -453,6 +453,8 @@ switch_language ru|en
Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users/<name>/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`:

View File

@@ -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`.

View File

@@ -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

View File

@@ -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