Release 1.0.0 with server info console
This commit is contained in:
19
README.md
19
README.md
@@ -46,12 +46,26 @@ UI: `http://<host>: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`.
|
||||
|
||||
|
||||
22
app/api/routes/system.py
Normal file
22
app/api/routes/system.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
186
app/core/server_info.py
Normal file
186
app/core/server_info.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
6
main.py
6
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
|
||||
|
||||
225
openapi.json
225
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": {
|
||||
|
||||
107
static/app.js
107
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) => {
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
</div>
|
||||
<nav class="flex glass p-1 rounded-xl">
|
||||
<button @click="tab = 'control'" :class="tab === 'control' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ПУЛЬТ</button>
|
||||
<button @click="tab = 'server'" :class="tab === 'server' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">СЕРВЕР</button>
|
||||
<button v-if="isAdmin" @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ГРАФИК</button>
|
||||
<button v-if="isAdmin" @click="tab = 'admin'" :class="tab === 'admin' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">АДМИНКА</button>
|
||||
<button v-if="isAdmin" @click="tab = 'stats'; fetchStats()" :class="tab === 'stats' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">СТАТА</button>
|
||||
@@ -60,6 +61,96 @@
|
||||
<div class="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full spinner"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'server' && !isLoading" class="space-y-8 fade-up">
|
||||
<section v-if="serverInfo" class="glass p-6 rounded-2xl">
|
||||
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.2em] text-slate-600 mb-2">О сервере</div>
|
||||
<h2 class="text-2xl font-black tracking-tight">{{ serverDisplayName() }}</h2>
|
||||
<div class="text-sm text-slate-500 mt-1">{{ serverDisplaySubtitle() }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-[10px]">
|
||||
<span class="border px-2.5 py-1 rounded-lg font-bold uppercase text-green-300 border-green-500/30 bg-green-500/10">
|
||||
онлайн
|
||||
</span>
|
||||
<span :class="isAdmin ? 'text-blue-300 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||||
{{ isAdmin ? 'админ-доступ' : 'гостевой доступ' }}
|
||||
</span>
|
||||
<span v-if="serverInfo.diagnostics_visible" class="border px-2.5 py-1 rounded-lg font-bold uppercase text-orange-300 border-orange-500/30 bg-orange-500/10">
|
||||
расширенная диагностика
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mt-6">
|
||||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||||
<div class="text-[10px] uppercase text-slate-600 mb-2">Статус</div>
|
||||
<div class="text-sm font-bold">Подключено</div>
|
||||
<div class="text-[11px] text-slate-500 mt-1">API отвечает и готов к управлению.</div>
|
||||
</div>
|
||||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||||
<div class="text-[10px] uppercase text-slate-600 mb-2">Запущен</div>
|
||||
<div class="text-sm font-bold">{{ formatRelativeUptime(serverInfo.uptime_seconds) }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-1">
|
||||
{{ serverInfo.started_at ? formatServerTimestamp(serverInfo.started_at) : `аптайм ${formatUptime(serverInfo.uptime_seconds)}` }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||||
<div class="text-[10px] uppercase text-slate-600 mb-2">Доступ</div>
|
||||
<div class="text-sm font-bold">{{ isMaster ? 'Мастер' : (isAdmin ? 'Администратор' : 'Гостевой') }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-1">
|
||||
{{ isAdmin ? 'Служебная диагностика доступна на этой вкладке.' : 'Служебные параметры и адреса скрыты.' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="serverInfo.diagnostics_visible" class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||||
<div class="text-[10px] uppercase text-slate-600 mb-2">Версия</div>
|
||||
<div class="text-sm font-bold">{{ serverInfo.build?.version ? `v${serverInfo.build.version}` : 'не определена' }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-1">{{ serverInfo.timezone || 'таймзона не указана' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="serverInfo?.diagnostics_visible" class="glass p-6 rounded-2xl">
|
||||
<h2 class="text-lg font-black uppercase mb-5">Диагностика</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||||
<div class="text-[10px] uppercase text-slate-600 mb-2">Сборка</div>
|
||||
<div class="text-sm font-bold">{{ formatServerBuild(serverInfo.build) }}</div>
|
||||
</div>
|
||||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||||
<div class="text-[10px] uppercase text-slate-600 mb-2">Таймзона</div>
|
||||
<div class="text-sm font-bold">{{ serverInfo.timezone || 'не указана' }}</div>
|
||||
</div>
|
||||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||||
<div class="text-[10px] uppercase text-slate-600 mb-2">Публичный URL</div>
|
||||
<div class="text-xs mono break-all text-slate-300">
|
||||
{{ serverInfo.urls?.effective_public_base_url || 'не определён' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||||
<div class="text-[10px] uppercase text-slate-600 mb-2">Наблюдаемый URL</div>
|
||||
<div class="text-xs mono break-all text-slate-300">
|
||||
{{ serverInfo.urls?.observed_base_url || 'не определён' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-[10px] mt-5">
|
||||
<span :class="serverInfo.configuration?.configured ? 'text-green-300 border-green-500/30 bg-green-500/10' : 'text-red-300 border-red-500/30 bg-red-500/10'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||||
{{ serverInfo.configuration?.configured ? 'настроен' : 'требует настройки' }}
|
||||
</span>
|
||||
<span :class="serverInfo.configuration?.public_base_url_configured ? 'text-blue-300 border-blue-500/30 bg-blue-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||||
{{ serverInfo.configuration?.public_base_url_configured ? 'public url задан' : 'public url не задан' }}
|
||||
</span>
|
||||
<span :class="serverInfo.configuration?.scan_network_configured ? 'text-cyan-300 border-cyan-500/30 bg-cyan-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||||
{{ serverInfo.configuration?.scan_network_configured ? 'scan network задан' : 'scan network auto' }}
|
||||
</span>
|
||||
<span :class="serverInfo.configuration?.build_metadata_complete ? 'text-orange-300 border-orange-500/30 bg-orange-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||||
{{ serverInfo.configuration?.build_metadata_complete ? 'build metadata полная' : 'build metadata частичная' }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ПУЛЬТ -->
|
||||
<div v-if="tab === 'control' && !isLoading" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div v-if="Object.keys(groups).length === 0" class="col-span-full text-center py-20 glass rounded-2xl">
|
||||
|
||||
@@ -104,6 +104,78 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase):
|
||||
response.json()["detail"], "Сервер не настроен: задайте IGNIS_API_KEY"
|
||||
)
|
||||
|
||||
async def test_system_info_returns_installed_server_metadata_without_secrets(
|
||||
self,
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"IGNIS_INSTANCE_NAME": "Home Lab",
|
||||
"APP_TIMEZONE": "Europe/Moscow",
|
||||
"IGNIS_PUBLIC_BASE_URL": "https://ignis.example.local/",
|
||||
"IGNIS_BUILD_VERSION": "1.2.3",
|
||||
"IGNIS_BUILD_DATE": "2026-05-21T10:11:12Z",
|
||||
"IGNIS_GIT_SHA": "abcdef1234567890",
|
||||
"SCAN_NETWORK": "192.168.0.0/24",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
response = await self.client.get(
|
||||
"/system/info",
|
||||
headers=self._master_headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["app_name"], "Ignis Core")
|
||||
self.assertEqual(payload["instance_name"], "Home Lab")
|
||||
self.assertEqual(payload["timezone"], "Europe/Moscow")
|
||||
self.assertEqual(payload["build"]["version"], "1.2.3")
|
||||
self.assertEqual(payload["build"]["build_date"], "2026-05-21T10:11:12Z")
|
||||
self.assertEqual(payload["build"]["git_sha"], "abcdef1234567890")
|
||||
self.assertEqual(
|
||||
payload["urls"]["configured_public_base_url"],
|
||||
"https://ignis.example.local",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["urls"]["effective_public_base_url"],
|
||||
"https://ignis.example.local",
|
||||
)
|
||||
self.assertEqual(payload["urls"]["observed_base_url"], "http://testserver")
|
||||
self.assertTrue(payload["configuration"]["configured"])
|
||||
self.assertTrue(payload["configuration"]["master_key_configured"])
|
||||
self.assertTrue(payload["configuration"]["scan_network_configured"])
|
||||
self.assertTrue(payload["configuration"]["public_base_url_configured"])
|
||||
self.assertTrue(payload["configuration"]["build_metadata_complete"])
|
||||
self.assertIn("started_at", payload)
|
||||
self.assertNotIn(MASTER_KEY, response.text)
|
||||
self.assertNotIn("api_key", payload)
|
||||
|
||||
async def test_guest_key_can_read_system_info(self):
|
||||
create_response = await self.client.post(
|
||||
"/api-keys",
|
||||
headers=self._master_headers(),
|
||||
params={"name": "wall-panel"},
|
||||
)
|
||||
guest_key = create_response.json()["key"]
|
||||
|
||||
response = await self.client.get(
|
||||
"/system/info",
|
||||
headers={"X-API-Key": guest_key},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["app_name"], "Ignis Core")
|
||||
self.assertGreaterEqual(payload["uptime_seconds"], 0)
|
||||
self.assertFalse(payload["diagnostics_visible"])
|
||||
self.assertNotIn("instance_name", payload)
|
||||
self.assertNotIn("timezone", payload)
|
||||
self.assertNotIn("started_at", payload)
|
||||
self.assertNotIn("build", payload)
|
||||
self.assertNotIn("urls", payload)
|
||||
self.assertNotIn("configuration", payload)
|
||||
|
||||
async def test_master_can_create_key_and_list_endpoint_returns_public_id(self):
|
||||
create_response = await self.client.post(
|
||||
"/api-keys",
|
||||
|
||||
@@ -50,3 +50,8 @@ class UiSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertNotIn("localStorage", index_html)
|
||||
self.assertNotIn("localStorage", app_js)
|
||||
self.assertIn("sessionStorage", app_js)
|
||||
self.assertIn("/system/info", app_js)
|
||||
self.assertIn("serverInfo", app_js)
|
||||
self.assertIn("О сервере", index_html)
|
||||
self.assertIn("СЕРВЕР", index_html)
|
||||
self.assertIn("Запущен", index_html)
|
||||
|
||||
Reference in New Issue
Block a user