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 @@
{{ service.url }}
+