Add configurable TIMEZONE for web UI display (UTC storage preserved)

This commit is contained in:
2026-07-17 12:02:58 +07:00
parent 559025d5f7
commit 513b8ede84
12 changed files with 131 additions and 7 deletions
+1
View File
@@ -17,6 +17,7 @@ class Settings(BaseSettings):
NOTIFICATION_WEBHOOK_URL: str = "" NOTIFICATION_WEBHOOK_URL: str = ""
NOTIFICATION_TELEGRAM_BOT_TOKEN: str = "" NOTIFICATION_TELEGRAM_BOT_TOKEN: str = ""
NOTIFICATION_TELEGRAM_CHAT_ID: str = "" NOTIFICATION_TELEGRAM_CHAT_ID: str = ""
TIMEZONE: str = "Asia/Krasnoyarsk"
class Config: class Config:
env_file = ".env" env_file = ".env"
+19 -1
View File
@@ -1,4 +1,4 @@
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -6,6 +6,8 @@ from contextlib import asynccontextmanager
from database import create_tables, run_migrations, SessionLocal from database import create_tables, run_migrations, SessionLocal
from auth import create_default_admin from auth import create_default_admin
from routes import auth_router, api_router, web_router from routes import auth_router, api_router, web_router
from settings_store import get_setting
from timezone_utils import set_current_timezone
@asynccontextmanager @asynccontextmanager
@@ -37,6 +39,22 @@ app.add_middleware(
allow_headers=["*"], 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(auth_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(web_router) app.include_router(web_router)
+6 -1
View File
@@ -11,9 +11,13 @@ from auth import verify_token, require_admin
from models import Computer, Heartbeat, User, Notification, AuditLog, Setting from models import Computer, Heartbeat, User, Notification, AuditLog, Setting
from notifications import check_offline_computers, get_unread_count from notifications import check_offline_computers, get_unread_count
from settings_store import get_all_settings from settings_store import get_all_settings
from timezone_utils import localtime, localtime_iso, get_common_timezones, get_current_timezone
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates") 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): 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 = [] latest_sessions = []
for h in heartbeats: for h in heartbeats:
hb_data.append({ hb_data.append({
"timestamp": h.timestamp.isoformat() if h.timestamp else None, "timestamp": localtime_iso(h.timestamp),
"username": h.username, "username": h.username,
"local_ip": h.local_ip, "local_ip": h.local_ip,
"cpu_percent": h.cpu_percent, "cpu_percent": h.cpu_percent,
@@ -223,6 +227,7 @@ def admin_settings_page(request: Request, db: Session = Depends(get_db)):
"request": request, "request": request,
"user": current_user, "user": current_user,
"settings": settings, "settings": settings,
"timezones": get_common_timezones(),
"unread_count": get_unread_count(db), "unread_count": get_unread_count(db),
}, },
) )
+1
View File
@@ -137,3 +137,4 @@ class SettingsUpdate(BaseModel):
NOTIFICATION_WEBHOOK_URL: Optional[str] = None NOTIFICATION_WEBHOOK_URL: Optional[str] = None
NOTIFICATION_TELEGRAM_BOT_TOKEN: Optional[str] = None NOTIFICATION_TELEGRAM_BOT_TOKEN: Optional[str] = None
NOTIFICATION_TELEGRAM_CHAT_ID: Optional[str] = None NOTIFICATION_TELEGRAM_CHAT_ID: Optional[str] = None
TIMEZONE: Optional[str] = None
+1
View File
@@ -39,6 +39,7 @@ def get_all_settings(db: Session) -> dict:
"NOTIFICATION_WEBHOOK_URL", "NOTIFICATION_WEBHOOK_URL",
"NOTIFICATION_TELEGRAM_BOT_TOKEN", "NOTIFICATION_TELEGRAM_BOT_TOKEN",
"NOTIFICATION_TELEGRAM_CHAT_ID", "NOTIFICATION_TELEGRAM_CHAT_ID",
"TIMEZONE",
] ]
for key in keys: for key in keys:
db_val = db.query(Setting).filter(Setting.key == key).first() db_val = db.query(Setting).filter(Setting.key == key).first()
+1 -1
View File
@@ -22,7 +22,7 @@
<tbody> <tbody>
{% for a in audit %} {% for a in audit %}
<tr> <tr>
<td class="text-nowrap">{{ a.timestamp.strftime('%Y-%m-%d %H:%M:%S') if a.timestamp else '-' }}</td> <td class="text-nowrap">{{ a.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ a.username or '-' }}</td> <td>{{ a.username or '-' }}</td>
<td><span class="badge bg-info">{{ a.action }}</span></td> <td><span class="badge bg-info">{{ a.action }}</span></td>
<td class="text-muted small">{{ a.details or '-' }}</td> <td class="text-muted small">{{ a.details or '-' }}</td>
+10
View File
@@ -31,6 +31,15 @@
<label class="form-label">NOTIFICATION_TELEGRAM_CHAT_ID</label> <label class="form-label">NOTIFICATION_TELEGRAM_CHAT_ID</label>
<input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_CHAT_ID" value="{{ settings.NOTIFICATION_TELEGRAM_CHAT_ID or '' }}"> <input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_CHAT_ID" value="{{ settings.NOTIFICATION_TELEGRAM_CHAT_ID or '' }}">
</div> </div>
<div class="mb-3">
<label class="form-label">Часовой пояс</label>
<select class="form-select" id="TIMEZONE">
{% for tz in timezones %}
<option value="{{ tz }}" {% if settings.TIMEZONE == tz %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
<div class="form-text">UTC хранится как есть, в веб-интерфейсе время отображается в выбранном поясе.</div>
</div>
<button type="submit" class="btn btn-primary">Сохранить</button> <button type="submit" class="btn btn-primary">Сохранить</button>
</form> </form>
<div id="settings-error" class="alert alert-danger mt-3 d-none"></div> <div id="settings-error" class="alert alert-danger mt-3 d-none"></div>
@@ -51,6 +60,7 @@ document.getElementById('settings-form').addEventListener('submit', async (e) =>
NOTIFICATION_WEBHOOK_URL: document.getElementById('NOTIFICATION_WEBHOOK_URL').value, NOTIFICATION_WEBHOOK_URL: document.getElementById('NOTIFICATION_WEBHOOK_URL').value,
NOTIFICATION_TELEGRAM_BOT_TOKEN: document.getElementById('NOTIFICATION_TELEGRAM_BOT_TOKEN').value, NOTIFICATION_TELEGRAM_BOT_TOKEN: document.getElementById('NOTIFICATION_TELEGRAM_BOT_TOKEN').value,
NOTIFICATION_TELEGRAM_CHAT_ID: document.getElementById('NOTIFICATION_TELEGRAM_CHAT_ID').value, NOTIFICATION_TELEGRAM_CHAT_ID: document.getElementById('NOTIFICATION_TELEGRAM_CHAT_ID').value,
TIMEZONE: document.getElementById('TIMEZONE').value,
}; };
const res = await fetch('/api/settings', { const res = await fetch('/api/settings', {
method: 'POST', method: 'POST',
+1 -1
View File
@@ -50,7 +50,7 @@
<td> <td>
<span class="badge {% if u.role == 'admin' %}bg-danger{% else %}bg-secondary{% endif %}">{{ u.role }}</span> <span class="badge {% if u.role == 'admin' %}bg-danger{% else %}bg-secondary{% endif %}">{{ u.role }}</span>
</td> </td>
<td>{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else '-' }}</td> <td>{{ u.created_at | localtime }}</td>
<td> <td>
<button class="btn btn-sm btn-outline-primary change-role" data-id="{{ u.id }}" data-username="{{ u.username }}" data-role="{{ u.role }}">Роль</button> <button class="btn btn-sm btn-outline-primary change-role" data-id="{{ u.id }}" data-username="{{ u.username }}" data-role="{{ u.role }}">Роль</button>
<button class="btn btn-sm btn-outline-warning reset-password" data-id="{{ u.id }}" data-username="{{ u.username }}">Пароль</button> <button class="btn btn-sm btn-outline-warning reset-password" data-id="{{ u.id }}" data-username="{{ u.username }}">Пароль</button>
+3
View File
@@ -7,6 +7,9 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script>
window.SERVER_TIMEZONE = "{{ current_timezone() }}";
</script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
+18 -2
View File
@@ -86,7 +86,7 @@
<td>{{ c.current_cpu_percent | round(1) if c.current_cpu_percent is not none else '-' }}%</td> <td>{{ c.current_cpu_percent | round(1) if c.current_cpu_percent is not none else '-' }}%</td>
<td>{{ c.current_ram_percent | round(1) if c.current_ram_percent is not none else '-' }}%</td> <td>{{ c.current_ram_percent | round(1) if c.current_ram_percent is not none else '-' }}%</td>
<td>{{ c.os_info or '-' }}</td> <td>{{ c.os_info or '-' }}</td>
<td class="last-seen">{% if c.last_seen %}{{ c.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}-{% endif %}</td> <td class="last-seen">{{ c.last_seen | localtime }}</td>
<td> <td>
<a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a> <a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a>
{% if user.role == 'admin' %} {% if user.role == 'admin' %}
@@ -116,6 +116,22 @@ function formatCurrentUser(value) {
return escapeHtml(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() { function currentQuery() {
return new URLSearchParams(window.location.search).toString(); return new URLSearchParams(window.location.search).toString();
} }
@@ -173,7 +189,7 @@ async function loadDashboard() {
<td>${c.current_cpu_percent != null ? c.current_cpu_percent.toFixed(1) : '-'}%</td> <td>${c.current_cpu_percent != null ? c.current_cpu_percent.toFixed(1) : '-'}%</td>
<td>${c.current_ram_percent != null ? c.current_ram_percent.toFixed(1) : '-'}%</td> <td>${c.current_ram_percent != null ? c.current_ram_percent.toFixed(1) : '-'}%</td>
<td>${escapeHtml(c.os_info || '-')}</td> <td>${escapeHtml(c.os_info || '-')}</td>
<td class="last-seen">${c.last_seen ? c.last_seen.replace('T', ' ').slice(0, 16) : '-'}</td> <td class="last-seen">${formatServerTime(c.last_seen)}</td>
<td> <td>
<a href="/computers/${c.id}" class="btn btn-sm btn-outline-primary">Детали</a> <a href="/computers/${c.id}" class="btn btn-sm btn-outline-primary">Детали</a>
${isAdmin ? `<button class="btn btn-sm btn-outline-danger delete-pc" data-id="${c.id}" data-hostname="${escapeHtml(c.hostname)}">Удалить</button>` : ''} ${isAdmin ? `<button class="btn btn-sm btn-outline-danger delete-pc" data-id="${c.id}" data-hostname="${escapeHtml(c.hostname)}">Удалить</button>` : ''}
+1 -1
View File
@@ -19,7 +19,7 @@
<div> <div>
<h6 class="card-title mb-1">{{ n.title }}</h6> <h6 class="card-title mb-1">{{ n.title }}</h6>
<p class="card-text mb-1 text-muted small">{{ n.message }}</p> <p class="card-text mb-1 text-muted small">{{ n.message }}</p>
<p class="card-text"><small class="text-muted">{{ n.sent_at.strftime('%Y-%m-%d %H:%M') if n.sent_at else '-' }}</small></p> <p class="card-text"><small class="text-muted">{{ n.sent_at | localtime }}</small></p>
</div> </div>
{% if not n.is_read %} {% if not n.is_read %}
<button class="btn btn-sm btn-outline-success mark-read" data-id="{{ n.id }}">Прочитать</button> <button class="btn btn-sm btn-outline-success mark-read" data-id="{{ n.id }}">Прочитать</button>
+69
View File
@@ -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()