diff --git a/README.md b/README.md index fd67f7c..45dbe11 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,11 @@ (ok-набор → ok, degraded-набор → деградация, всё прочее → вниз); не-2xx/таймаут = вниз. Полный контракт и рекомендации для сервисов — конвенция экосистемы в handbook: [10-platform/health.md](https://git.gnexus.space/root/gnexus-handbook/raw/master/10-platform/health.md). +Панель — референс-реализация парсера §5 «Самосвидетельство»: из расширенного тела +читаются identity (`service/version/build/environment/uptime`), `mode` +(обслуживание/readonly/draining — нейтральный бейдж) и `gauges` (числа, +чипами в карточке). Сырое тело не хранится; известные поля берутся по белому +списку. ## Язык интерфейса diff --git a/panel/backend/app/api/services.py b/panel/backend/app/api/services.py index 2ba826c..dea3e0f 100644 --- a/panel/backend/app/api/services.py +++ b/panel/backend/app/api/services.py @@ -7,6 +7,7 @@ минимум из services_probe (docs/health-endpoint-spec.md). """ +import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query @@ -40,7 +41,7 @@ db = get_db() cursor = await db.execute( """SELECT sv.*, p.ts AS p_ts, p.state AS p_state, p.code AS p_code, - p.latency_ms AS p_latency, p.message AS p_message + p.latency_ms AS p_latency, p.message AS p_message, p.report AS p_report FROM services sv LEFT JOIN service_samples p ON p.id = ( SELECT MAX(id) FROM service_samples WHERE service_id = sv.id @@ -56,6 +57,10 @@ age = (datetime.now(timezone.utc) - datetime.fromisoformat(row["p_ts"])).total_seconds() if age <= stale_after: status = row["p_state"] # up | degraded | down + try: + report = json.loads(row["p_report"]) if row["p_report"] else None + except ValueError: + report = None result.append( { "id": row["id"], @@ -67,6 +72,7 @@ "latency_ms": row["p_latency"] if status != "pending" else None, "code": row["p_code"] if status != "pending" else None, "message": row["p_message"] if status != "pending" else "", + "report": report, } ) return result @@ -136,17 +142,24 @@ params.append(since.astimezone(timezone.utc).isoformat()) params.append(limit) cursor = await db.execute( - f"SELECT ts, state, code, latency_ms FROM service_samples WHERE {' AND '.join(conditions)}" + f"SELECT ts, state, code, latency_ms, report FROM service_samples WHERE {' AND '.join(conditions)}" " ORDER BY ts DESC LIMIT ?", params, ) rows = await cursor.fetchall() - return [ - { - "ts": row["ts"], - "state": row["state"], - "code": row["code"], - "latency_ms": row["latency_ms"], - } - for row in reversed(rows) - ] \ No newline at end of file + points = [] + for row in reversed(rows): + try: + report = json.loads(row["report"]) if row["report"] else None + except ValueError: + report = None + points.append( + { + "ts": row["ts"], + "state": row["state"], + "code": row["code"], + "latency_ms": row["latency_ms"], + "report": report, + } + ) + return points \ No newline at end of file diff --git a/panel/backend/app/db.py b/panel/backend/app/db.py index e53401c..3452a1f 100644 --- a/panel/backend/app/db.py +++ b/panel/backend/app/db.py @@ -19,9 +19,20 @@ await _conn.execute("PRAGMA foreign_keys=ON") schema = (Path(__file__).with_name("schema.sql")).read_text(encoding="utf-8") await _conn.executescript(schema) + # Колонки, которых нет в старых БД: CREATE TABLE IF NOT EXISTS их не добавит. + await _try_alter("ALTER TABLE service_samples ADD COLUMN report TEXT") await _conn.commit() +async def _try_alter(stmt: str) -> None: + """ALTER для существующих таблиц; дубликат колонки — норма (уже добавлена).""" + try: + await _conn.execute(stmt) + except aiosqlite.OperationalError as exc: + if "duplicate column" not in str(exc).lower(): + raise + + async def close_db() -> None: global _conn if _conn is not None: diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql index f4d5df5..c947e22 100644 --- a/panel/backend/app/schema.sql +++ b/panel/backend/app/schema.sql @@ -92,7 +92,8 @@ state TEXT NOT NULL DEFAULT 'down', -- up | degraded | down code INTEGER, -- HTTP-код; NULL = сеть/таймаут/DNS latency_ms REAL NOT NULL DEFAULT 0, - message TEXT NOT NULL DEFAULT '' -- status из тела или текст сетевой ошибки + message TEXT NOT NULL DEFAULT '', -- status из тела или текст сетевой ошибки + report TEXT -- самосвидетельство (JSON §5 спеки; NULL = нет) ); CREATE INDEX IF NOT EXISTS idx_service_samples_service_ts ON service_samples(service_id, ts); diff --git a/panel/backend/app/services_probe.py b/panel/backend/app/services_probe.py index 06e1999..f3c8c7f 100644 --- a/panel/backend/app/services_probe.py +++ b/panel/backend/app/services_probe.py @@ -21,7 +21,7 @@ import httpx from app.config import get_settings -from app.db import get_db +from app.db import get_db, j RETENTION_DAYS = 7 @@ -32,31 +32,78 @@ OK_VALUES = {"ok", "healthy", "pass", "passing", "up"} DEGRADED_VALUES = {"degraded", "warn", "warning", "partial", "busy"} +# самосвидетельство §5 спеки: белые списки и лимиты +REPORT_MODES = {"maintenance", "readonly", "draining"} +REPORT_MAX_GAUGES = 20 -def classify(code: int, body: bytes | None) -> tuple[str, str]: - """(state, message) по коду и телу отклика. code задан, если дошёл до HTTP.""" + +def load_json(body: bytes | None) -> dict | list | None: + """JSON из тела health — всё прочее → None (не-JSON тело норма).""" + if not body: + return None + try: + return json.loads(body.decode("utf-8", errors="replace")) + except (ValueError, UnicodeError): + return None + + +def classify(code: int, payload: dict | list | None) -> tuple[str, str]: + """(state, message) по коду и разобранному телу; payload = load_json(body).""" if code < 200 or code >= 300: return "down", f"http {code}" - if body: - try: - payload = json.loads(body.decode("utf-8", errors="replace")) - except (ValueError, UnicodeError): - return "up", "" - if isinstance(payload, dict): - status = payload.get("status") - if isinstance(status, str): - value = status.strip().lower() - if value in OK_VALUES: - return "up", value - if value in DEGRADED_VALUES: - return "degraded", value - # не-словарный или незнакомый статус — счёт не в пользу сервиса - return "down", value + if isinstance(payload, dict): + status = payload.get("status") + if isinstance(status, str): + value = status.strip().lower() + if value in OK_VALUES: + return "up", value + if value in DEGRADED_VALUES: + return "degraded", value + # не-словарный или незнакомый статус — счёт не в пользу сервиса + return "down", value return "up", "" +def parse_report(payload: dict | list | None) -> dict: + """Самосвидетельство §5: берём ТОЛЬКО известные поля. + + Сырое тело не храним и не логируем: эндпоинт без авторизации, неизвестные + ключи могут содержать ПД. Неизвестное значение mode игнорируется — mode + не статус лайвности. + """ + if not isinstance(payload, dict): + return {} + report: dict = {} + for key in ("service", "version", "build", "environment"): + value = payload.get(key) + if isinstance(value, str) and (trimmed := value.strip()[:80]): + report[key] = trimmed + if isinstance(payload.get("uptime"), (int, float)) and not isinstance(payload["uptime"], bool): + report["uptime"] = int(payload["uptime"]) + since = payload.get("since") + if isinstance(since, str): + try: + uptime = (datetime.now(timezone.utc) - datetime.fromisoformat(since)).total_seconds() + report["uptime"] = int(max(0, uptime)) + except ValueError: + pass + mode = payload.get("mode") + if isinstance(mode, str) and (clean := mode.strip().lower()) in REPORT_MODES: + report["mode"] = clean + gauges = payload.get("gauges") + if isinstance(gauges, dict): + picked = {} + for name, value in list(gauges.items())[:REPORT_MAX_GAUGES]: + # bool — подмножество int: "cpu_warn": true считается сломанным, пропускаем + if isinstance(value, (int, float)) and not isinstance(value, bool) and isinstance(name, str): + picked[name[:40]] = round(float(value), 4) + if picked: + report["gauges"] = picked + return report + + async def probe_once(client: httpx.AsyncClient, url: str) -> dict: - """Один GET: {state, code, latency_ms, message}.""" + """Один GET: {state, code, latency_ms, message, report}.""" started = time.monotonic() code: int | None = None message = "" @@ -72,14 +119,19 @@ break body = b"".join(chunks)[:MAX_BODY] await response.aclose() - state, message = classify(code, body) + payload = load_json(body) + state, message = classify(code, payload) + report = parse_report(payload) # есть и на down (http-код): версия полезна except httpx.HTTPError as exc: # таймаут, DNS, отказ соединения + report = {} text = str(exc) message = (text.split(";")[0][:200] if text else type(exc).__name__) except Exception as exc: # noqa: BLE001 — любая ошибка = вниз, цикл не роняем + report = {} message = str(exc)[:200] latency_ms = round((time.monotonic() - started) * 1000, 1) - return {"state": state, "code": code, "latency_ms": latency_ms, "message": message} + return {"state": state, "code": code, "latency_ms": latency_ms, "message": message, + "report": report} async def sample_once() -> int: @@ -100,13 +152,15 @@ points = 0 for row, result in zip(rows, results): if isinstance(result, Exception): # проб по отдельному url не уронил волну - probe = {"state": "down", "code": None, "latency_ms": 0.0, "message": str(result)[:200]} + probe = {"state": "down", "code": None, "latency_ms": 0.0, + "message": str(result)[:200], "report": {}} else: probe = result await db.execute( - "INSERT INTO service_samples (service_id, ts, state, code, latency_ms, message)" - " VALUES (?, ?, ?, ?, ?, ?)", - (row["id"], now, probe["state"], probe["code"], probe["latency_ms"], probe["message"]), + "INSERT INTO service_samples (service_id, ts, state, code, latency_ms, message, report)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + (row["id"], now, probe["state"], probe["code"], probe["latency_ms"], + probe["message"], j(probe["report"])), ) points += 1 cutoff = (datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)).isoformat() @@ -124,12 +178,13 @@ probe = await probe_once(client, url) db = get_db() await db.execute( - "INSERT INTO service_samples (service_id, ts, state, code, latency_ms, message)" - " VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO service_samples (service_id, ts, state, code, latency_ms, message, report)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", ( service_id, datetime.now(timezone.utc).isoformat(), probe["state"], probe["code"], probe["latency_ms"], probe["message"], + j(probe["report"]), ), ) await db.commit() diff --git a/panel/frontend/src/i18n/messages/en.js b/panel/frontend/src/i18n/messages/en.js index e9a1275..196abd9 100644 --- a/panel/frontend/src/i18n/messages/en.js +++ b/panel/frontend/src/i18n/messages/en.js @@ -125,6 +125,9 @@ "services.stateDegraded": "degraded", "services.stateDown": "down", "services.statePending": "waiting", + "services.modeMaintenance": "maintenance", + "services.modeReadonly": "readonly", + "services.modeDraining": "draining", "services.notResponding": "not responding — {message}", "services.serviceAdded": "Service added", "services.historyFailed": "Service history failed to load", diff --git a/panel/frontend/src/i18n/messages/ru.js b/panel/frontend/src/i18n/messages/ru.js index ef7f9e0..ce33e7d 100644 --- a/panel/frontend/src/i18n/messages/ru.js +++ b/panel/frontend/src/i18n/messages/ru.js @@ -124,6 +124,9 @@ "services.stateDegraded": "деградация", "services.stateDown": "недоступен", "services.statePending": "ожидание", + "services.modeMaintenance": "обслуживание", + "services.modeReadonly": "только чтение", + "services.modeDraining": "снятие с нагрузки", "services.notResponding": "не отвечает — {message}", "services.serviceAdded": "Сервис добавлен", "services.historyFailed": "Не удалось загрузить историю сервисов", diff --git a/panel/frontend/src/i18n/messages/uk.js b/panel/frontend/src/i18n/messages/uk.js index 392dcde..0f8f01c 100644 --- a/panel/frontend/src/i18n/messages/uk.js +++ b/panel/frontend/src/i18n/messages/uk.js @@ -124,6 +124,9 @@ "services.stateDegraded": "деградація", "services.stateDown": "недоступний", "services.statePending": "очікування", + "services.modeMaintenance": "обслуговування", + "services.modeReadonly": "лише читання", + "services.modeDraining": "зняття з навантаження", "services.notResponding": "не відповідає — {message}", "services.serviceAdded": "Сервіс додано", "services.historyFailed": "Не вдалося завантажити історію сервісів", diff --git a/panel/frontend/src/pages/ServicesPage.vue b/panel/frontend/src/pages/ServicesPage.vue index 3a69919..b173adb 100644 --- a/panel/frontend/src/pages/ServicesPage.vue +++ b/panel/frontend/src/pages/ServicesPage.vue @@ -9,6 +9,7 @@ import { api } from '../api.js' import ChartLine from '../components/ChartLine.vue' import { CHART_COLORS } from '../chartTheme.js' +import { fmtUptime } from '../format.js' import { t } from '../i18n/index.js' const toast = useToast() @@ -64,6 +65,18 @@ const latencyText = (service) => t('services.latency', { n: service.latency_ms ?? '—' }) +// --- Самосвидетельство (§5 спеки): identity / mode / gauges ------------------- + +// mode — сознательное состояние (обслуживание/только чтение), не состояние живости +const modeText = (mode) => ({ + maintenance: t('services.modeMaintenance'), + readonly: t('services.modeReadonly'), + draining: t('services.modeDraining'), +}[mode] || mode) + +const hasIdentity = (report) => + report && (report.version || report.environment || report.uptime !== undefined) + // --- График: датасет на сервис; down/ошибки — null (разрыв линии) ------------ const chartDatasets = computed(() => @@ -167,11 +180,28 @@ {{ stateText(service.status) }} + + {{ modeText(service.report.mode) }} + {{ service.url }} +
+ v{{ service.report.version }} + {{ service.report.environment }} + + {{ t('server.uptime', { value: fmtUptime(service.report.uptime) }) }} + +
+ +
+ + {{ name }}: {{ value }} + +
+
{{ latencyText(service) }}
@@ -230,6 +260,9 @@