diff --git a/server/config.py b/server/config.py index d72fc84..4f2559a 100644 --- a/server/config.py +++ b/server/config.py @@ -17,6 +17,7 @@ class Settings(BaseSettings): NOTIFICATION_WEBHOOK_URL: str = "" NOTIFICATION_TELEGRAM_BOT_TOKEN: str = "" NOTIFICATION_TELEGRAM_CHAT_ID: str = "" + TIMEZONE: str = "Asia/Krasnoyarsk" class Config: env_file = ".env" diff --git a/server/main.py b/server/main.py index 74050ac..a6caa53 100644 --- a/server/main.py +++ b/server/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager @@ -6,6 +6,8 @@ from contextlib import asynccontextmanager from database import create_tables, run_migrations, SessionLocal from auth import create_default_admin from routes import auth_router, api_router, web_router +from settings_store import get_setting +from timezone_utils import set_current_timezone @asynccontextmanager @@ -37,6 +39,22 @@ app.add_middleware( allow_headers=["*"], ) + +@app.middleware("http") +async def timezone_middleware(request: Request, call_next): + """Set current request timezone from DB settings for web rendering.""" + db = SessionLocal() + try: + tz = get_setting(db, "TIMEZONE") + if tz: + set_current_timezone(tz) + else: + set_current_timezone("UTC") + finally: + db.close() + return await call_next(request) + + app.include_router(auth_router) app.include_router(api_router) app.include_router(web_router) diff --git a/server/routes/web_routes.py b/server/routes/web_routes.py index a81c1f8..73eac56 100644 --- a/server/routes/web_routes.py +++ b/server/routes/web_routes.py @@ -11,9 +11,13 @@ from auth import verify_token, require_admin from models import Computer, Heartbeat, User, Notification, AuditLog, Setting from notifications import check_offline_computers, get_unread_count from settings_store import get_all_settings +from timezone_utils import localtime, localtime_iso, get_common_timezones, get_current_timezone router = APIRouter() templates = Jinja2Templates(directory="templates") +templates.env.filters["localtime"] = localtime +templates.env.filters["localtime_iso"] = localtime_iso +templates.env.globals["current_timezone"] = get_current_timezone def user_or_redirect(request: Request, db: Session): @@ -118,7 +122,7 @@ def computer_page(computer_id: int, request: Request, db: Session = Depends(get_ latest_sessions = [] for h in heartbeats: hb_data.append({ - "timestamp": h.timestamp.isoformat() if h.timestamp else None, + "timestamp": localtime_iso(h.timestamp), "username": h.username, "local_ip": h.local_ip, "cpu_percent": h.cpu_percent, @@ -223,6 +227,7 @@ def admin_settings_page(request: Request, db: Session = Depends(get_db)): "request": request, "user": current_user, "settings": settings, + "timezones": get_common_timezones(), "unread_count": get_unread_count(db), }, ) diff --git a/server/schemas.py b/server/schemas.py index 43355ac..0b53fe2 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -137,3 +137,4 @@ class SettingsUpdate(BaseModel): NOTIFICATION_WEBHOOK_URL: Optional[str] = None NOTIFICATION_TELEGRAM_BOT_TOKEN: Optional[str] = None NOTIFICATION_TELEGRAM_CHAT_ID: Optional[str] = None + TIMEZONE: Optional[str] = None diff --git a/server/settings_store.py b/server/settings_store.py index 2ed9112..2d7a977 100644 --- a/server/settings_store.py +++ b/server/settings_store.py @@ -39,6 +39,7 @@ def get_all_settings(db: Session) -> dict: "NOTIFICATION_WEBHOOK_URL", "NOTIFICATION_TELEGRAM_BOT_TOKEN", "NOTIFICATION_TELEGRAM_CHAT_ID", + "TIMEZONE", ] for key in keys: db_val = db.query(Setting).filter(Setting.key == key).first() diff --git a/server/templates/admin_audit.html b/server/templates/admin_audit.html index f25895c..5c76a8a 100644 --- a/server/templates/admin_audit.html +++ b/server/templates/admin_audit.html @@ -22,7 +22,7 @@ {% for a in audit %} - {{ a.timestamp.strftime('%Y-%m-%d %H:%M:%S') if a.timestamp else '-' }} + {{ a.timestamp | localtime('%Y-%m-%d %H:%M:%S') }} {{ a.username or '-' }} {{ a.action }} {{ a.details or '-' }} diff --git a/server/templates/admin_settings.html b/server/templates/admin_settings.html index f5609f3..08be024 100644 --- a/server/templates/admin_settings.html +++ b/server/templates/admin_settings.html @@ -31,6 +31,15 @@ +
+ + +
UTC хранится как есть, в веб-интерфейсе время отображается в выбранном поясе.
+
@@ -51,6 +60,7 @@ document.getElementById('settings-form').addEventListener('submit', async (e) => NOTIFICATION_WEBHOOK_URL: document.getElementById('NOTIFICATION_WEBHOOK_URL').value, NOTIFICATION_TELEGRAM_BOT_TOKEN: document.getElementById('NOTIFICATION_TELEGRAM_BOT_TOKEN').value, NOTIFICATION_TELEGRAM_CHAT_ID: document.getElementById('NOTIFICATION_TELEGRAM_CHAT_ID').value, + TIMEZONE: document.getElementById('TIMEZONE').value, }; const res = await fetch('/api/settings', { method: 'POST', diff --git a/server/templates/admin_users.html b/server/templates/admin_users.html index 86bf256..0edb24d 100644 --- a/server/templates/admin_users.html +++ b/server/templates/admin_users.html @@ -50,7 +50,7 @@ {{ u.role }} - {{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else '-' }} + {{ u.created_at | localtime }} diff --git a/server/templates/base.html b/server/templates/base.html index 57b7b3d..4bd4099 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -7,6 +7,9 @@ + {% block head %}{% endblock %} diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 9ac485a..b6734b2 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -86,7 +86,7 @@ {{ c.current_cpu_percent | round(1) if c.current_cpu_percent is not none else '-' }}% {{ c.current_ram_percent | round(1) if c.current_ram_percent is not none else '-' }}% {{ c.os_info or '-' }} - {% if c.last_seen %}{{ c.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}-{% endif %} + {{ c.last_seen | localtime }} Детали {% if user.role == 'admin' %} @@ -116,6 +116,22 @@ function formatCurrentUser(value) { return escapeHtml(value); } +function formatServerTime(isoString) { + if (!isoString) return '-'; + const d = new Date(isoString); + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: window.SERVER_TIMEZONE, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + hour12: false, + }).formatToParts(d); + const p = {}; + for (const part of parts) { + if (part.type !== 'literal') p[part.type] = part.value; + } + return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`; +} + function currentQuery() { return new URLSearchParams(window.location.search).toString(); } @@ -173,7 +189,7 @@ async function loadDashboard() { ${c.current_cpu_percent != null ? c.current_cpu_percent.toFixed(1) : '-'}% ${c.current_ram_percent != null ? c.current_ram_percent.toFixed(1) : '-'}% ${escapeHtml(c.os_info || '-')} - ${c.last_seen ? c.last_seen.replace('T', ' ').slice(0, 16) : '-'} + ${formatServerTime(c.last_seen)} Детали ${isAdmin ? `` : ''} diff --git a/server/templates/notifications.html b/server/templates/notifications.html index a963025..22156dd 100644 --- a/server/templates/notifications.html +++ b/server/templates/notifications.html @@ -19,7 +19,7 @@
{{ n.title }}

{{ n.message }}

-

{{ n.sent_at.strftime('%Y-%m-%d %H:%M') if n.sent_at else '-' }}

+

{{ n.sent_at | localtime }}

{% if not n.is_read %} diff --git a/server/timezone_utils.py b/server/timezone_utils.py new file mode 100644 index 0000000..b4b35f1 --- /dev/null +++ b/server/timezone_utils.py @@ -0,0 +1,69 @@ +"""Timezone helpers for web UI.""" +from contextvars import ContextVar +from datetime import datetime +from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +_current_timezone: ContextVar[str] = ContextVar("current_timezone", default="UTC") + + +def set_current_timezone(tz: str) -> None: + _current_timezone.set(tz) + + +def get_current_timezone() -> str: + return _current_timezone.get() + + +def get_common_timezones() -> list[str]: + """Return a curated list of common IANA timezones for the UI.""" + return [ + "UTC", + "Europe/Moscow", + "Europe/Samara", + "Asia/Krasnoyarsk", + "Asia/Novosibirsk", + "Asia/Irkutsk", + "Asia/Yakutsk", + "Asia/Vladivostok", + "Asia/Kamchatka", + "Asia/Almaty", + "Asia/Tashkent", + "Asia/Tbilisi", + ] + + +def _ensure_aware(dt: datetime, default_tz: str = "UTC") -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=ZoneInfo(default_tz)) + return dt + + +def localtime( + value: Optional[datetime], + tz: Optional[str] = None, + fmt: str = "%Y-%m-%d %H:%M", +) -> str: + """Convert a UTC datetime to the configured timezone and format it.""" + if value is None: + return "-" + tz_name = tz or get_current_timezone() + try: + zone = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + zone = ZoneInfo("UTC") + dt = _ensure_aware(value) + return dt.astimezone(zone).strftime(fmt) + + +def localtime_iso(value: Optional[datetime], tz: Optional[str] = None) -> str: + """Convert a UTC datetime to the configured timezone and return ISO string.""" + if value is None: + return "" + tz_name = tz or get_current_timezone() + try: + zone = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + zone = ZoneInfo("UTC") + dt = _ensure_aware(value) + return dt.astimezone(zone).isoformat()