Add configurable TIMEZONE for web UI display (UTC storage preserved)
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>` : ''}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user