diff --git a/README.md b/README.md index 09fa357..20bdaac 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,26 @@ UI: `http://:8000/` ```env IGNIS_API_KEY=change-me +IGNIS_INSTANCE_NAME=Home APP_TIMEZONE=Asia/Novosibirsk LOG_LEVEL=INFO IGNIS_DATABASE_URL=sqlite+aiosqlite:///./ignis.db IGNIS_SYNC_DATABASE_URL=sqlite:///./ignis.db ``` +Параметры server metadata / versioning: + +```env +IGNIS_PUBLIC_BASE_URL=https://ignis.example.local +IGNIS_BUILD_VERSION=1.0.0 +IGNIS_BUILD_DATE=2026-05-21T12:00:00Z +IGNIS_GIT_SHA=abc1234def56 +``` + +- `IGNIS_INSTANCE_NAME` — человекочитаемое имя инстанса, которое видно в UI и `GET /system/info`. +- `IGNIS_PUBLIC_BASE_URL` — внешний URL сервера, если он стоит за reverse proxy или доступен по доменному имени. +- `IGNIS_BUILD_VERSION`, `IGNIS_BUILD_DATE`, `IGNIS_GIT_SHA` — build metadata установленного сервера для диагностики и сверки версий. + Параметры discovery: ```env @@ -114,6 +128,7 @@ EVENT_LOG_RETENTION_DAYS=30 - `POST /api-keys/activate` - `GET /stats/summary` - `GET /stats/log` +- `GET /system/info` `control/*` и `schedules/*` принимают JSON body. @@ -149,6 +164,7 @@ curl -X POST http://127.0.0.1:8000/control/group/bedroom \ - использует только локальные ассеты; - не использует `localStorage`; - может хранить API-ключ только в `sessionStorage` текущей вкладки; +- показывает build/server metadata текущего инстанса; - умеет базовое управление группами, расписания, API-ключи, stats/log и быстрый таймер на 4 часа. ## Хранилище @@ -176,7 +192,7 @@ SQLite-таблицы: ## Тесты -На 2026-05-16 в `tests/` лежит 27 `unittest`-сценариев. +На 2026-05-21 в `tests/` лежит 29 `unittest`-сценариев. Покрыто: @@ -189,6 +205,7 @@ SQLite-таблицы: - auto subnet selection для discovery; - background offline cleanup threshold; - manual rescan summary; +- server metadata endpoint и отсутствие утечки секретов в нём; - security headers и локальные UI-ассеты; - stats summary без двойного счёта `*_requested`. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/app/api/routes/system.py b/app/api/routes/system.py new file mode 100644 index 0000000..b2f95c5 --- /dev/null +++ b/app/api/routes/system.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, Request + +from app.api.deps import AuthContext, verify_token +from app.api.schemas import ServerInfoResponse +from app.core.server_info import build_server_info + +router = APIRouter(dependencies=[Depends(verify_token)]) + + +@router.get( + "/info", + response_model=ServerInfoResponse, + response_model_exclude_none=True, +) +async def get_system_info( + request: Request, + auth: AuthContext = Depends(verify_token), +): + return build_server_info( + observed_base_url=str(request.base_url), + include_diagnostics=auth.is_admin, + ) diff --git a/app/api/schemas.py b/app/api/schemas.py index 71af782..919e6a7 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -178,3 +178,35 @@ class RescanResponse(BaseModel): removed_offline: int pending_removal: int online: int + + +class ServerBuildInfoResponse(BaseModel): + version: str | None = None + git_sha: str | None = None + build_date: str | None = None + + +class ServerUrlInfoResponse(BaseModel): + observed_base_url: str | None = None + configured_public_base_url: str | None = None + effective_public_base_url: str | None = None + + +class ServerConfigurationStatusResponse(BaseModel): + configured: bool + master_key_configured: bool + scan_network_configured: bool + public_base_url_configured: bool + build_metadata_complete: bool + + +class ServerInfoResponse(BaseModel): + app_name: str + instance_name: str | None = None + timezone: str | None = None + uptime_seconds: int + diagnostics_visible: bool + started_at: str | None = None + build: ServerBuildInfoResponse | None = None + urls: ServerUrlInfoResponse | None = None + configuration: ServerConfigurationStatusResponse | None = None diff --git a/app/core/server_info.py b/app/core/server_info.py new file mode 100644 index 0000000..9101552 --- /dev/null +++ b/app/core/server_info.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import os +from pathlib import Path +import socket + +from app.api.deps import get_master_key + +APP_NAME = "Ignis Core" +PROJECT_ROOT = Path(__file__).resolve().parents[2] +VERSION_FILE = PROJECT_ROOT / "VERSION" +SERVER_STARTED_AT = datetime.now(timezone.utc) + + +@dataclass(frozen=True) +class ServerBuildInfo: + version: str + git_sha: str | None + build_date: str | None + + +@dataclass(frozen=True) +class ServerUrlInfo: + observed_base_url: str | None + configured_public_base_url: str | None + effective_public_base_url: str | None + + +@dataclass(frozen=True) +class ServerConfigurationStatus: + configured: bool + master_key_configured: bool + scan_network_configured: bool + public_base_url_configured: bool + build_metadata_complete: bool + + +def _clean_env(name: str) -> str | None: + value = os.getenv(name, "").strip() + return value or None + + +def _normalize_url(value: str | None) -> str | None: + if not value: + return None + normalized = value.strip().rstrip("/") + return normalized or None + + +def _read_version_file() -> str | None: + try: + value = VERSION_FILE.read_text(encoding="utf-8").strip() + except OSError: + return None + return value or None + + +def get_app_version() -> str: + return ( + _clean_env("IGNIS_BUILD_VERSION") + or _read_version_file() + or "1.0.0" + ) + + +def _resolve_git_ref(git_dir: Path, ref_name: str) -> str | None: + ref_path = git_dir / ref_name + if ref_path.exists(): + try: + value = ref_path.read_text(encoding="utf-8").strip() + except OSError: + return None + return value or None + + packed_refs = git_dir / "packed-refs" + if not packed_refs.exists(): + return None + + try: + lines = packed_refs.read_text(encoding="utf-8").splitlines() + except OSError: + return None + + for line in lines: + if not line or line.startswith("#") or line.startswith("^"): + continue + sha, _, candidate_ref = line.partition(" ") + if candidate_ref.strip() == ref_name: + return sha.strip() or None + + return None + + +def _read_git_sha() -> str | None: + git_dir = PROJECT_ROOT / ".git" + if not git_dir.exists(): + return None + + head_path = git_dir / "HEAD" + try: + head_value = head_path.read_text(encoding="utf-8").strip() + except OSError: + return None + + if not head_value: + return None + + if head_value.startswith("ref: "): + resolved = _resolve_git_ref(git_dir, head_value[5:].strip()) + if not resolved: + return None + return resolved[:12] + + return head_value[:12] + + +def get_build_info() -> ServerBuildInfo: + git_sha = _clean_env("IGNIS_GIT_SHA") or _read_git_sha() + build_date = _clean_env("IGNIS_BUILD_DATE") + return ServerBuildInfo( + version=get_app_version(), + git_sha=git_sha, + build_date=build_date, + ) + + +def get_instance_name() -> str: + return _clean_env("IGNIS_INSTANCE_NAME") or socket.gethostname() + + +def get_server_urls(observed_base_url: str | None) -> ServerUrlInfo: + configured_public_base_url = _normalize_url(_clean_env("IGNIS_PUBLIC_BASE_URL")) + observed = _normalize_url(observed_base_url) + return ServerUrlInfo( + observed_base_url=observed, + configured_public_base_url=configured_public_base_url, + effective_public_base_url=configured_public_base_url or observed, + ) + + +def get_configuration_status(build_info: ServerBuildInfo) -> ServerConfigurationStatus: + master_key_configured = get_master_key() is not None + public_base_url_configured = _clean_env("IGNIS_PUBLIC_BASE_URL") is not None + scan_network_configured = _clean_env("SCAN_NETWORK") is not None + build_metadata_complete = bool(build_info.version and build_info.git_sha and build_info.build_date) + return ServerConfigurationStatus( + configured=master_key_configured, + master_key_configured=master_key_configured, + scan_network_configured=scan_network_configured, + public_base_url_configured=public_base_url_configured, + build_metadata_complete=build_metadata_complete, + ) + + +def get_uptime_seconds() -> int: + delta = datetime.now(timezone.utc) - SERVER_STARTED_AT + return max(int(delta.total_seconds()), 0) + + +def build_server_info( + *, + observed_base_url: str | None = None, + include_diagnostics: bool, +) -> dict: + payload = { + "app_name": APP_NAME, + "uptime_seconds": get_uptime_seconds(), + "diagnostics_visible": include_diagnostics, + } + if not include_diagnostics: + return payload + + build_info = get_build_info() + payload["instance_name"] = get_instance_name() + payload["timezone"] = os.getenv("APP_TIMEZONE", "Asia/Novosibirsk") + payload["started_at"] = SERVER_STARTED_AT.isoformat() + payload["build"] = { + "version": build_info.version, + "git_sha": build_info.git_sha, + "build_date": build_info.build_date, + } + payload["urls"] = asdict(get_server_urls(observed_base_url)) + payload["configuration"] = asdict(get_configuration_status(build_info)) + return payload diff --git a/deploy/ignis-core.env.example b/deploy/ignis-core.env.example index 36b0260..df0d51e 100644 --- a/deploy/ignis-core.env.example +++ b/deploy/ignis-core.env.example @@ -1,4 +1,9 @@ IGNIS_API_KEY=change-me +IGNIS_INSTANCE_NAME=Home +IGNIS_PUBLIC_BASE_URL= +IGNIS_BUILD_VERSION=1.0.0 +IGNIS_BUILD_DATE= +IGNIS_GIT_SHA= APP_TIMEZONE=Asia/Novosibirsk SCAN_NETWORK=192.168.0.0/24 DISCOVERY_INTERVAL_SECONDS=600 diff --git a/main.py b/main.py index 03be3f7..59455c5 100644 --- a/main.py +++ b/main.py @@ -6,11 +6,12 @@ from fastapi import FastAPI, Depends from fastapi.staticfiles import StaticFiles from app.core.database import init_db, async_session +from app.core.server_info import get_app_version from app.core.scheduler import start_scheduler from app.core.state import state_manager, discovery_service from sqlalchemy import select from app.models.device import GroupModel -from app.api.routes import devices, control, schedules, api_keys, stats +from app.api.routes import devices, control, schedules, api_keys, stats, system from app.api.deps import verify_token LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() @@ -61,7 +62,7 @@ async def lifespan(app: FastAPI): logger.info("🛑 Ignis Core остановлен") -app = FastAPI(title="Ignis Core API", lifespan=lifespan) +app = FastAPI(title="Ignis Core API", version=get_app_version(), lifespan=lifespan) @app.middleware("http") @@ -87,6 +88,7 @@ app.include_router(control.router, prefix="/control", tags=["Control"]) app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"]) app.include_router(api_keys.router, prefix="/api-keys", tags=["API Keys"]) app.include_router(stats.router, prefix="/stats", tags=["Stats"]) +app.include_router(system.router, prefix="/system", tags=["System"]) # Статика # Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html diff --git a/openapi.json b/openapi.json index f6515aa..54c66df 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Ignis Core API", - "version": "0.1.0" + "version": "1.0.0" }, "paths": { "/devices": { @@ -879,6 +879,32 @@ } } }, + "/system/info": { + "get": { + "tags": [ + "System" + ], + "summary": "Get System Info", + "operationId": "get_system_info_system_info_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfoResponse" + } + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, "/": { "get": { "summary": "Read Index", @@ -1745,6 +1771,203 @@ ], "title": "ScheduleTasksResponse" }, + "ServerBuildInfoResponse": { + "properties": { + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + }, + "git_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Git Sha" + }, + "build_date": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Build Date" + } + }, + "type": "object", + "title": "ServerBuildInfoResponse" + }, + "ServerConfigurationStatusResponse": { + "properties": { + "configured": { + "type": "boolean", + "title": "Configured" + }, + "master_key_configured": { + "type": "boolean", + "title": "Master Key Configured" + }, + "scan_network_configured": { + "type": "boolean", + "title": "Scan Network Configured" + }, + "public_base_url_configured": { + "type": "boolean", + "title": "Public Base Url Configured" + }, + "build_metadata_complete": { + "type": "boolean", + "title": "Build Metadata Complete" + } + }, + "type": "object", + "required": [ + "configured", + "master_key_configured", + "scan_network_configured", + "public_base_url_configured", + "build_metadata_complete" + ], + "title": "ServerConfigurationStatusResponse" + }, + "ServerInfoResponse": { + "properties": { + "app_name": { + "type": "string", + "title": "App Name" + }, + "instance_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Instance Name" + }, + "timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Timezone" + }, + "uptime_seconds": { + "type": "integer", + "title": "Uptime Seconds" + }, + "diagnostics_visible": { + "type": "boolean", + "title": "Diagnostics Visible" + }, + "started_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "build": { + "anyOf": [ + { + "$ref": "#/components/schemas/ServerBuildInfoResponse" + }, + { + "type": "null" + } + ] + }, + "urls": { + "anyOf": [ + { + "$ref": "#/components/schemas/ServerUrlInfoResponse" + }, + { + "type": "null" + } + ] + }, + "configuration": { + "anyOf": [ + { + "$ref": "#/components/schemas/ServerConfigurationStatusResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "app_name", + "uptime_seconds", + "diagnostics_visible" + ], + "title": "ServerInfoResponse" + }, + "ServerUrlInfoResponse": { + "properties": { + "observed_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Observed Base Url" + }, + "configured_public_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Configured Public Base Url" + }, + "effective_public_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Effective Public Base Url" + } + }, + "type": "object", + "title": "ServerUrlInfoResponse" + }, "ValidationError": { "properties": { "loc": { diff --git a/static/app.js b/static/app.js index 5276eec..8ee69ad 100644 --- a/static/app.js +++ b/static/app.js @@ -39,6 +39,7 @@ createApp({ isAdmin: false, isMaster: false, authName: "", + serverInfo: null, groups: {}, devices: [], sliders: {}, @@ -86,6 +87,7 @@ createApp({ this.isAdmin = false; this.isMaster = false; this.authName = ""; + this.serverInfo = null; this.groups = {}; this.devices = []; this.sliders = {}; @@ -115,6 +117,104 @@ createApp({ const group = this.groups[targetId]; return group ? group.name : null; }, + serverDisplayName() { + if (this.serverInfo?.instance_name) { + return this.serverInfo.instance_name; + } + return this.serverInfo?.app_name || "Ignis Core"; + }, + serverDisplaySubtitle() { + if (this.serverInfo?.instance_name) { + return this.serverInfo.app_name || "Ignis Core"; + } + if (this.serverInfo?.diagnostics_visible) { + return "Подключение активно и готово к управлению."; + } + return "Подключение активно. Операционная диагностика скрыта для гостевого доступа."; + }, + shortSha(value) { + if (!value) { + return null; + } + return value.length <= 7 ? value : value.slice(0, 7); + }, + formatServerTimestamp(iso) { + if (!iso) { + return "unknown"; + } + + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) { + return iso; + } + + const pad = (value) => String(value).padStart(2, "0"); + return `${parsed.getUTCFullYear()}-${pad(parsed.getUTCMonth() + 1)}-${pad(parsed.getUTCDate())} ${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())} UTC`; + }, + formatServerBuild(build) { + if (!build) { + return "build info unavailable"; + } + + const parts = [`v${build.version}`]; + const shortSha = this.shortSha(build.git_sha); + if (shortSha) { + parts.push(shortSha); + } + if (build.build_date) { + parts.push(this.formatServerTimestamp(build.build_date)); + } + return parts.join(" · "); + }, + formatDuration(totalSeconds, maxParts = 2) { + const seconds = Math.max(Number(totalSeconds || 0), 0); + if (seconds < 60) { + return "меньше минуты"; + } + + const units = [ + { size: 86400, forms: ["день", "дня", "дней"] }, + { size: 3600, forms: ["час", "часа", "часов"] }, + { size: 60, forms: ["минута", "минуты", "минут"] }, + ]; + let remaining = seconds; + const parts = []; + + units.forEach((unit) => { + if (parts.length >= maxParts) { + return; + } + const value = Math.floor(remaining / unit.size); + if (value <= 0) { + return; + } + parts.push(`${value} ${this.pluralRu(value, ...unit.forms)}`); + remaining -= value * unit.size; + }); + + return parts.join(" "); + }, + formatUptime(totalSeconds) { + return this.formatDuration(totalSeconds, 2); + }, + formatRelativeUptime(totalSeconds) { + const seconds = Math.max(Number(totalSeconds || 0), 0); + if (seconds < 60) { + return "только что"; + } + return `${this.formatDuration(seconds, 2)} назад`; + }, + pluralRu(count, one, few, many) { + const mod10 = count % 10; + const mod100 = count % 100; + if (mod10 === 1 && mod100 !== 11) { + return one; + } + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { + return few; + } + return many; + }, async request(path, { method = "GET", query = null, body = null } = {}) { let url = path; if (query) { @@ -185,12 +285,17 @@ createApp({ this.isFetching = true; try { - const [groupsData, devicesData, scenesData] = await Promise.all([ + const [serverInfoData, groupsData, devicesData, scenesData] = await Promise.all([ + this.request("/system/info"), this.request("/devices/groups"), this.request("/devices"), this.request("/devices/scenes"), ]); + if (serverInfoData) { + this.serverInfo = serverInfoData; + } + if (groupsData) { this.groups = groupsData; Object.keys(this.groups).forEach((id) => { diff --git a/static/index.html b/static/index.html index 70c2d0b..cd80ad0 100644 --- a/static/index.html +++ b/static/index.html @@ -50,6 +50,7 @@