diff --git a/README.md b/README.md index ce94ec7..0f37a37 100644 --- a/README.md +++ b/README.md @@ -265,21 +265,31 @@ "disks": {"/": [41, 98]}} // min/max по метрикам и по каждому маунту ``` -График рисует по нему полосу min–max, поэтому всплеск CPU или заполнение диска -двухминутной давности видны и через год, а `n` против `exp` показывает часы с -простоем. Само падение при этом пишется ещё и в журнал событий (`server_offline` -/`server_online` с минутами простоя) — журнал хранится год, чтобы факт падения -не зависел от разряжения метрик. API отдаёт дайджест полем `agg` точки -(`GET /api/v1/servers/{id}/metrics`), MCP-тул `server_history` — там же, при -своём прореживании он сливает дайджесты, а не выбрасывает их. +График рисует по нему полосу min–max (без сглаживания — по точным крайним +значениям, иначе полоса выгибалась бы за них), поэтому всплеск CPU или +заполнение диска двухминутной давности видны и через год, а `n` против `exp` +показывает часы с простоем. Маунт, попавший в дайджест, но не доживший до +выжившей точки (диск примонтировали на короткое время), остаётся в легенде — +по полосе видно, до чего он доходил. Само падение при этом пишется ещё и в +журнал событий (`server_offline`/`server_online` с минутами простоя) — журнал +хранится год, чтобы факт падения не зависел от разряжения метрик. API отдаёт +дайджест полем `agg` точки (`GET /api/v1/servers/{id}/metrics`), MCP-тул +`server_history` — там же, при своём прореживании он сливает дайджесты, а не +выбрасывает их. Ответ истории тоже прореживается, и тоже без потери краёв: `limit` — это -сколько точек вернуть максимум, а если в окне их больше, они прореживаются -**равномерно по всему периоду** (`points[0]` — начало запрошенного интервала, -`points[-1]` — сейчас), дайджесты выброшенных точек сливаются в оставшуюся. -Обрезка по свежему краю была бы хуже вдвойне: запрос за неделю отдавал бы -только последние часы — сырые сутки вытесняли всю старую историю вместе с -дайджестами, и полосы на длинных периодах просто не доезжали до графика. +сколько точек вернуть максимум, а если в окне их больше, окно делится на +**равные интервалы времени**, и в каждом остаётся своя точка (`points[0]` — +начало запрошенного интервала, `points[-1]` — сейчас), дайджесты выброшенных +точек сливаются в оставшуюся. Обрезка по свежему краю была бы хуже вдвойне: +запрос за неделю отдавал бы только последние часы — сырые сутки вытесняли всю +старую историю вместе с дайджестами, и полосы на длинных периодах просто не +доезжали до графика. Режем именно по времени, а не по числу точек: при нарезке +по счёту редкие ярусы (часовая точка старше недели) раздуваются до ширины +сырых суток, и неделя ужималась в первые проценты ширины графика. Страница +сервера просит точек по длине периода (400 для часа, 600 для суток и недели) и +опрашивает историю тем реже, чем длиннее период (10 с … 60 с) — на графике +шириной в несколько сот пикселей больше точек всё равно не различить. Отдельно от истории живёт таблица `server_state` — «состояние последнего пакета» на сервер: сырые счётчики сети (нужны, чтобы посчитать МБ/с от diff --git a/panel/backend/app/api/servers.py b/panel/backend/app/api/servers.py index 15f76da..1346154 100644 --- a/panel/backend/app/api/servers.py +++ b/panel/backend/app/api/servers.py @@ -186,11 +186,12 @@ ) -> list[dict]: """История метрик (графики): время, cpu, ram, сеть. По возрастанию ts. - `limit` — сколько точек вернуть максимум. Если в окне их больше, они - равномерно прореживаются по всему окну (а не обрезаются по свежему краю: - иначе запрос за неделю отдавал бы только последние часы — сырые сутки - вытесняют всю старую историю); дайджесты выброшенных точек сливаются в - оставшуюся, поэтому пики и простои не теряются. + `limit` — сколько точек вернуть максимум. Если в окне их больше, окно + делится на равные интервалы времени и в каждом остаётся точка — по всему + окну, а не с обрезкой по свежему краю (иначе запрос за неделю отдавал бы + только последние часы — сырые сутки вытесняют всю старую историю); + дайджесты выброшенных точек сливаются в оставшуюся, поэтому пики и простои + не теряются. У схлопнутых бакетов (старше суток) приходит `agg` — дайджест интервала (min/max по метрикам, n/exp замеров). @@ -214,6 +215,9 @@ params, ) rows = await cursor.fetchall() + # disks_json — самый объёмный JSON в ответе; разбираем его только у точек, + # которые выживут после прореживания (agg разбирать приходится у всех: его + # крайности сливаются в оставшуюся точку). points = [ { "ts": row["ts"], @@ -224,11 +228,13 @@ "uptime": row["uptime"], "net_in_mbs": row["net_in_mbs"], "net_out_mbs": row["net_out_mbs"], - "disks": json.loads(row["disks_json"]), "agg": json.loads(row["agg_json"] or "{}") or None, + "disks_raw": row["disks_json"], } for row in reversed(rows) ] if len(points) > limit: points = thin_points(points, limit) + for point in points: + point["disks"] = json.loads(point.pop("disks_raw")) return points \ No newline at end of file diff --git a/panel/backend/app/digest.py b/panel/backend/app/digest.py index 4ac9e22..95dd56c 100644 --- a/panel/backend/app/digest.py +++ b/panel/backend/app/digest.py @@ -8,25 +8,62 @@ дайджест и хранится. Прореживание в БД (по возрасту точки) живёт отдельно — в -`metrics_retention.thin_once`, потому что считает бакеты по календарю, а не по -числу точек; здесь же группы нарезаются равномерно по окну ответа. +`metrics_retention.thin_once`, потому что считает бакеты по календарю; здесь же +окно ответа режется на слоты равной длины, чтобы ось графика осталась +пропорциональной времени. """ +from datetime import datetime, timezone + + +def _parse_ts(ts: str) -> datetime: + """ts строки всегда с офсетом (`ingest._iso`), но наивный не должен ронять ответ.""" + parsed = datetime.fromisoformat(ts) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + def thin_points(points: list[dict], max_points: int) -> list[dict]: """Проредить точки (по возрастанию ts) до max_points, сливая их дайджесты. - В каждой группе остаётся последняя точка — самая свежая, поэтому свежий - край окна не теряется; в её `agg` собираются крайности всей группы. + Окно от первой точки до последней делится на `max_points` слотов равной + длительности, и в каждом остаётся последняя попавшая в него точка — самая + свежая, так что свежий край окна не теряется. В её `agg` собираются + крайности всех точек слота. + + Режем по времени, а не по числу точек: при нарезке по счёту редкие ярусы + хранения (часовая точка старше недели) раздуваются до ширины сырых суток — + неделя ужималась в первые проценты ширины графика. Со слотами равной длины + ось почти пропорциональна времени; неточность остаётся только там, где + данные в окне неоднородны (самая старая точка может сдвинуться вправо на + длину слота). + + Точку с ts не нашего формата пропускаем: на ось её всё равно не поставить, + а ронять из-за одной строки весь ответ незачем (ts пишет только ingest, и + только через `_iso`, так что такой строке взяться неоткуда). """ - step = len(points) / max_points + if max_points < 1 or len(points) <= max_points: + return points + anchor: datetime | None = None + stamped: list[tuple[float, dict]] = [] + for point in points: + try: + ts = _parse_ts(point["ts"]) + except ValueError: + continue + if anchor is None: + anchor = ts + stamped.append(((ts - anchor).total_seconds(), point)) + if not stamped: + return [] + span = stamped[-1][0] # окно меряем от первой разобранной точки (ts по возрастанию) + slot = span / max_points if span > 0 else 0.0 + slots: dict[int, list[dict]] = {} + for offset, point in stamped: + index = min(int(offset / slot), max_points - 1) if slot > 0 else 0 + slots.setdefault(index, []).append(point) out = [] - for i in range(max_points): - lo = int(i * step) - hi = int((i + 1) * step) if i + 1 < max_points else len(points) - if lo >= hi: - break # вырожденные слоты возможны только при max_points > len(points) - group = points[lo:hi] + for index in sorted(slots): + group = slots[index] point = dict(group[-1]) digest: dict = {} for member in group: diff --git a/panel/backend/app/mcp.py b/panel/backend/app/mcp.py index d3b4ba5..4424ab8 100644 --- a/panel/backend/app/mcp.py +++ b/panel/backend/app/mcp.py @@ -126,9 +126,9 @@ - `server_history(hours)`: разумно 1 (детали), 6, 24 (сутки), 168 (неделя) или 720 (месяц). История разряжается по возрасту точки: первые сутки — как прислал агент, 1–3 сут — точка в минуту, 3–7 сут — точка в 5 минут, дальше — - точка в час; старше года точки удаляются. Окно покрывается целиком (точки - прореживаются по нему равномерно, первая — начало периода, последняя — - сейчас). Вопросы вида «был ли всплеск или + точка в час; старше года точки удаляются. Окно покрывается целиком (делится + на равные интервалы времени, в каждом остаётся точка: первая — начало + периода, последняя — сейчас). Вопросы вида «был ли всплеск или простой» решай по `agg` точки (min/max за интервал и n/exp замеров), а не по значению самой точки: точка — это последний замер интервала, а пик мог быть внутри. @@ -276,9 +276,10 @@ агента, 1–3 сут — точка в минуту, 3–7 сут — точка в 5 минут, дальше — точка в час; старше года точки удаляются. - Если точек в окне больше `max_points`, они прореживаются равномерно по - всему окну (а не обрезаются по свежему краю) — окно всегда покрыто - целиком: `points[0]` — начало запрошенного периода, `points[-1]` — сейчас. + Если точек в окне больше `max_points`, окно делится на равные интервалы + времени и в каждом остаётся точка (а не обрезается по свежему краю) — окно + всегда покрыто целиком: `points[0]` — начало запрошенного периода, + `points[-1]` — сейчас. У схлопнутых точек (старше суток) есть `agg` — дайджест интервала: `n`/`exp` (сколько замеров пришло из ожидаемых — n < exp значит простой),