diff --git a/README.md b/README.md
index f9ced0e..bf3ee4d 100644
--- a/README.md
+++ b/README.md
@@ -296,6 +296,12 @@
чипами в карточке). Сырое тело не хранится; известные поля берутся по белому
списку.
+Карточки сервисов пытаются подтянуть мета с главной страницы origin'а
+(`
` и favicon, обновление раз в 24 ч): если есть — отображается
+favicon и заголовок страницы вместо заданного имени (заданное имя остаётся
+каноничным — подсказка и редактирование его не меняют). Нет мета — как раньше,
+заданное имя.
+
Общий график «все сервисы» в списке не показывается (масштабы latency не
сравнимы). У каждого сервиса своя страница (`/services/{id}`): персональный
график времени отклика с периодами 1h/6h/24h/7d (down-точки — разрыв линии),
diff --git a/panel/backend/app/api/services.py b/panel/backend/app/api/services.py
index 7b78c9f..0ff74ae 100644
--- a/panel/backend/app/api/services.py
+++ b/panel/backend/app/api/services.py
@@ -16,6 +16,7 @@
from app.config import get_settings
from app.db import get_db
from app.security import require_admin
+from app.service_meta import refresh_meta_now
from app.services_probe import probe_service_once
router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_admin)])
@@ -73,6 +74,8 @@
"code": row["p_code"] if status != "pending" else None,
"message": row["p_message"] if status != "pending" else "",
"report": report,
+ "site_title": row["site_title"],
+ "icon": row["icon"],
}
)
return result
@@ -86,11 +89,15 @@
(body.name, body.url, _now_iso()),
)
await db.commit()
- # первая проба сразу — не ждём ближайшей волны пробера
+ # первая проба и мета (title/favicon) сразу — не ждём ближайшей волны пробера
try:
await probe_service_once(cursor.lastrowid, body.url)
except Exception as exc: # замер доберёт фоновый цикл
print(f"services probe error (on create): {exc}", flush=True)
+ try:
+ await refresh_meta_now(cursor.lastrowid, body.url)
+ except Exception as exc: # мета доберёт фоновый цикл
+ print(f"service meta error (on create): {exc}", flush=True)
return {"id": cursor.lastrowid, "name": body.name, "url": body.url}
@@ -102,7 +109,12 @@
if body.name is not None:
await db.execute("UPDATE services SET name = ? WHERE id = ?", (body.name, service_id))
if body.url is not None:
- await db.execute("UPDATE services SET url = ? WHERE id = ?", (body.url, service_id))
+ # старая мета могла описывать чужой сайт — сброс до тяги по новому URL
+ await db.execute(
+ "UPDATE services SET url = ?, site_title = NULL, icon = NULL, meta_fetched_at = NULL"
+ " WHERE id = ?",
+ (body.url, service_id),
+ )
cursor = await db.execute("SELECT changes()")
(changed,) = await cursor.fetchone()
if not changed:
@@ -112,6 +124,7 @@
if body.url is not None: # URL сменился — пробуем не дожидаясь волны
try:
probe = await probe_service_once(service_id, body.url)
+ await refresh_meta_now(service_id, body.url)
except Exception as exc:
print(f"services probe error (on update): {exc}", flush=True)
cursor = await db.execute("SELECT id, name, url, created_at FROM services WHERE id = ?", (service_id,))
diff --git a/panel/backend/app/db.py b/panel/backend/app/db.py
index 4c478bd..d116710 100644
--- a/panel/backend/app/db.py
+++ b/panel/backend/app/db.py
@@ -23,6 +23,9 @@
await _try_alter("ALTER TABLE service_samples ADD COLUMN report TEXT")
await _try_alter("ALTER TABLE users ADD COLUMN system_role TEXT")
await _try_alter("ALTER TABLE users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
+ await _try_alter("ALTER TABLE services ADD COLUMN site_title TEXT")
+ await _try_alter("ALTER TABLE services ADD COLUMN icon TEXT")
+ await _try_alter("ALTER TABLE services ADD COLUMN meta_fetched_at TEXT")
await _conn.commit()
diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql
index 84bdc76..7d12e96 100644
--- a/panel/backend/app/schema.sql
+++ b/panel/backend/app/schema.sql
@@ -79,10 +79,13 @@
-- degraded-набор → degraded, всё прочее → down;
-- не-2xx/таймаут/отказ → down (точка всё равно пишется).
CREATE TABLE IF NOT EXISTS services (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- url TEXT NOT NULL, -- полный health URL сервиса
- created_at TEXT NOT NULL
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ url TEXT NOT NULL, -- полный health URL сервиса
+ site_title TEXT, -- главной страницы (или NULL)
+ icon TEXT, -- favicon data-URL (или NULL)
+ meta_fetched_at TEXT, -- отметка последней попытки тяги мета
+ created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS service_samples (
diff --git a/panel/backend/app/service_meta.py b/panel/backend/app/service_meta.py
new file mode 100644
index 0000000..92e0d9f
--- /dev/null
+++ b/panel/backend/app/service_meta.py
@@ -0,0 +1,167 @@
+"""Мета сервиса с главной страницы: `` и favicon.
+
+Health-URL может быть глубже корня — мета тянем с origin (scheme://netloc).
+HTML главной не хранится: из него берутся только (строка ≤100) и href
+первой ``; иконка кладётся в `services.icon` data-URL'ом
+(b64, ≤100 KB). Неудача — тоже законная попытка: метка meta_fetched_at
+пишется всегда, чтобы не долбить мёртвый origin каждую волну проб.
+"""
+
+import base64
+from datetime import datetime, timedelta, timezone
+from html.parser import HTMLParser
+from urllib.parse import urljoin, urlsplit
+
+import httpx
+
+from app.config import get_settings
+from app.db import get_db
+
+META_TTL_S = 24 * 3600
+MAX_PAGE = 256 * 1024 # хватает на любого разумного сайта
+MAX_ICON = 100 * 1024
+
+TITLE_MAX = 100
+
+# rel ``-иконок в порядке приоритета
+_ICON_RELS = {"icon": 0, "shortcut icon": 0, "mask-icon": 1, "apple-touch-icon": 1}
+
+
+class _HeadParser(HTMLParser):
+ """Первые и — остальное не интересно."""
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.title: str | None = None
+ self.icon_url: str | None = None
+ self.icon_rel: int | None = None # приоритет уже найденного
+ self._in_title = False
+ self._title_done = False
+
+ def handle_starttag(self, tag: str, attrs: list) -> None:
+ if tag == "title" and not self._title_done:
+ self._in_title = True
+ self.title = ""
+ elif tag == "link":
+ values = dict(attrs)
+ rel = (values.get("rel") or "").strip().lower()
+ if rel in _ICON_RELS and values.get("href"):
+ rank = _ICON_RELS[rel]
+ if self.icon_rel is None or rank < self.icon_rel:
+ self.icon_rel = rank
+ self.icon_url = values["href"]
+
+ def handle_endtag(self, tag: str) -> None:
+ if tag == "title" and self._in_title:
+ self._in_title = False
+ self._title_done = True
+ cleaned = self.title.strip()
+ self.title = cleaned[:TITLE_MAX] if cleaned else None
+
+ def handle_data(self, data: str) -> None:
+ if self._in_title and len(self.title) < TITLE_MAX + 20:
+ self.title += data
+
+
+def _mime(content: bytes, header: str | None) -> str | None:
+ """image/* из заголовка, иначе снифф по магии; не картинка → None."""
+ if header and header.split(";")[0].strip().lower().startswith("image/"):
+ return header.split(";")[0].strip().lower()
+ if content.startswith(b"\x89PNG"):
+ return "image/png"
+ if content.startswith(b"\xff\xd8"):
+ return "image/jpeg"
+ if content.startswith(b"GIF8"):
+ return "image/gif"
+ if content.startswith(b"\x00\x00\x01\x00"):
+ return "image/x-icon"
+ if content.lstrip()[:4].lower() == b"