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, -- <title> главной страницы (или 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 @@ +"""Мета сервиса с главной страницы: `<title>` и favicon. + +Health-URL может быть глубже корня — мета тянем с origin (scheme://netloc). +HTML главной не хранится: из него берутся только <title> (строка ≤100) и href +первой `<link rel=icon…>`; иконка кладётся в `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 # хватает на <head> любого разумного сайта +MAX_ICON = 100 * 1024 + +TITLE_MAX = 100 + +# rel `<link>`-иконок в порядке приоритета +_ICON_RELS = {"icon": 0, "shortcut icon": 0, "mask-icon": 1, "apple-touch-icon": 1} + + +class _HeadParser(HTMLParser): + """Первые <title> и <link rel=icon> — остальное не интересно.""" + + 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"<svg": + return "image/svg+xml" + return None + + +async def _read_limited(client: httpx.AsyncClient, url: str, cap: int) -> tuple[int, str, bytes | None]: + """GET с ранним разрывом после cap байт: (код, content-type, тело|None).""" + try: + response = await client.send(client.build_request("GET", url), stream=True) + chunks: list[bytes] = [] + async for chunk in response.aiter_bytes(4096): + chunks.append(chunk) + if sum(map(len, chunks)) >= cap: + break + await response.aclose() + if response.status_code != 200: + return response.status_code, "", None + return response.status_code, response.headers.get("content-type", ""), b"".join(chunks)[:cap] + except httpx.HTTPError: + return 0, "", None + + +def _origin(url: str) -> str | None: + parts = urlsplit(url.strip()) + if not parts.scheme or not parts.netloc: + return None + return f"{parts.scheme}://{parts.netloc}" + + +async def refresh_meta(client: httpx.AsyncClient, service_id: int, url: str) -> None: + """Тяну-меты одного сервиса: title главной + favicon. Всегда пишет метку.""" + db = get_db() + site_title: str | None = None + icon: str | None = None + + origin = _origin(url) + if origin is not None: + code, ctype, page = await _read_limited(client, origin, MAX_PAGE) + if code == 200 and page and "html" in ctype: + parser = _HeadParser() + parser.feed(page.decode("utf-8", errors="replace")) + site_title = parser.title + if parser.icon_url: + icon_url = urljoin(origin, parser.icon_url) + else: # консервативный фоллбэк — иконка в корне + icon_url = origin + "/favicon.ico" + icode, ictype, icon_bytes = await _read_limited(client, icon_url, MAX_ICON) + mime = _mime(icon_bytes or b"", ictype) + if icon_bytes and mime: + # иконка в <img> не исполняет скрипты — svg безопасен + icon = f"data:{mime};base64," + base64.b64encode(icon_bytes).decode() + + await db.execute( + "UPDATE services SET site_title = ?, icon = ?, meta_fetched_at = ? WHERE id = ?", + ( + site_title, + icon, + datetime.now(timezone.utc).isoformat(), + service_id, + ), + ) + await db.commit() + + +async def refresh_meta_now(service_id: int, url: str) -> None: + """Разовая мета-тяга со своим клиентом (create/update в API).""" + settings = get_settings() + async with httpx.AsyncClient( + timeout=settings.health_timeout, follow_redirects=True, verify=False + ) as client: + await refresh_meta(client, service_id, url) + + +async def needs_meta_refresh(db, ttl_s: int = META_TTL_S) -> list[dict]: + """Сервисы, чья мета не тянулась или старше ttl.""" + cursor = await db.execute("SELECT id, url, meta_fetched_at FROM services") + rows = await cursor.fetchall() + threshold = datetime.now(timezone.utc) - timedelta(seconds=ttl_s) + due = [] + for row in rows: + fetched = row["meta_fetched_at"] + if not fetched: + due.append({"id": row["id"], "url": row["url"]}) + continue + try: + if datetime.fromisoformat(fetched) < threshold: + due.append({"id": row["id"], "url": row["url"]}) + except ValueError: + due.append({"id": row["id"], "url": row["url"]}) + return due \ No newline at end of file diff --git a/panel/backend/app/services_probe.py b/panel/backend/app/services_probe.py index 0ee881d..229fb43 100644 --- a/panel/backend/app/services_probe.py +++ b/panel/backend/app/services_probe.py @@ -23,6 +23,7 @@ from app.config import get_settings from app.db import get_db, j from app.events import record_event +from app.service_meta import needs_meta_refresh, refresh_meta RETENTION_DAYS = 7 @@ -146,6 +147,13 @@ async with httpx.AsyncClient( timeout=settings.health_timeout, follow_redirects=True, verify=False ) as client: + # мета (title/favicon, ttl 24 ч) тянется параллельно волне проб + meta_task = None + if (due := await needs_meta_refresh(db)): + meta_task = asyncio.gather( + *(refresh_meta(client, item["id"], item["url"]) for item in due), + return_exceptions=True, + ) results = await asyncio.gather( *(probe_once(client, row["url"]) for row in rows), return_exceptions=True ) @@ -169,6 +177,8 @@ prev_states.get(row["id"]), probe, service_id=row["id"], db=db, ) points += 1 + if meta_task is not None: + await meta_task # refresh_meta сам пишет в БД и коммитит cutoff = (datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)).isoformat() await db.execute("DELETE FROM service_samples WHERE ts < ?", (cutoff,)) await db.commit() diff --git a/panel/frontend/src/pages/ServicePage.vue b/panel/frontend/src/pages/ServicePage.vue index 446e070..8955b72 100644 --- a/panel/frontend/src/pages/ServicePage.vue +++ b/panel/frontend/src/pages/ServicePage.vue @@ -233,7 +233,7 @@ </GnEmptyState> <template v-else> - <GnPageHeader :title="service.name" :subtitle="service.url" kicker="GHard Monitor"> + <GnPageHeader :title="service.site_title || service.name" :subtitle="service.url" kicker="GHard Monitor"> <template #meta> <GnBadge :variant="stateVariant(service.status)">{{ stateText(service.status) }}</GnBadge> <GnBadge v-if="service.report?.mode" variant="secondary"> diff --git a/panel/frontend/src/pages/ServicesPage.vue b/panel/frontend/src/pages/ServicesPage.vue index 3033018..d71512e 100644 --- a/panel/frontend/src/pages/ServicesPage.vue +++ b/panel/frontend/src/pages/ServicesPage.vue @@ -125,7 +125,10 @@ > <template #title> <span class="service-title"> - <span class="service-name">{{ service.name }}</span> + <img v-if="service.icon" class="service-favicon" :src="service.icon" alt=""> + <span class="service-name" :title="service.name"> + {{ service.site_title || service.name }} + </span> <GnBadge :variant="stateVariant(service.status)"> {{ stateText(service.status) }} </GnBadge> @@ -229,6 +232,13 @@ overflow: hidden; text-overflow: ellipsis; } +.service-favicon { + width: 18px; + height: 18px; + border-radius: 3px; + flex-shrink: 0; + object-fit: contain; +} .service-url { display: block; font-size: 13px;