Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9018f83b9 | ||
|
|
e1372b0584 | ||
|
|
5e77c162c7 | ||
|
|
49a884c01d | ||
|
|
3b5ac3d282 | ||
|
|
1560592af4 | ||
|
|
76f74201e5 | ||
|
|
513b8ede84 | ||
|
|
559025d5f7 | ||
|
|
7b2e5172ea | ||
|
|
8e62eaf3f3 | ||
|
|
677f6028d9 | ||
|
|
d1128f4aa3 |
@@ -1,33 +1,46 @@
|
||||
# InfoUser Monitor
|
||||
|
||||
Мониторинг состояния ПК (Windows/Linux/macOS). Агент на каждом компьютере отправляет метрики на сервер раз в час, а веб-интерфейс показывает список машин, пользователей, IP, CPU/RAM, диски и процессы.
|
||||
Мониторинг состояния ПК (Windows/Linux/macOS). Агент на каждом компьютере отправляет метрики на сервер раз в час, а веб-интерфейс показывает список машин, пользователей, IP, CPU/RAM/swap, диски, процессы и активные пользовательские сессии.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Дашборд** с фильтрами по статусу, пользователю и поиском по хосту/IP/OS
|
||||
- **Графики** CPU/RAM за 24 часа на странице ПК
|
||||
- **Детали ПК**: графики CPU/RAM/swap за 24 часа, активные сессии, топ процессов, история heartbeats
|
||||
- **Активные сессии**: кто и с какого IP залогинен прямо сейчас
|
||||
- **Уведомления** об офлайн-ПК (in-app + webhook + Telegram)
|
||||
- **Экспорт** списка ПК в CSV
|
||||
- **JWT-авторизация** с ролями admin/viewer
|
||||
- **RBAC и аудит-лог**: управление пользователями, запись действий админов
|
||||
- **Настройки через веб**: токен агента, порог офлайна, уведомления, часовой пояс
|
||||
- **Часовой пояс**: хранение в UTC, отображение в выбранном поясе
|
||||
- **RAM и swap**: использование в GB и процентах
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
info-user/
|
||||
├── server/ # FastAPI + веб-интерфейс
|
||||
│ ├── routes/
|
||||
│ ├── templates/
|
||||
│ ├── static/
|
||||
│ ├── routes/ # API и веб-роуты
|
||||
│ ├── templates/ # Jinja2-шаблоны
|
||||
│ ├── static/ # CSS/JS
|
||||
│ ├── database.py # SQLite и миграции
|
||||
│ ├── models.py # SQLAlchemy-модели
|
||||
│ ├── schemas.py # Pydantic-схемы
|
||||
│ ├── Dockerfile
|
||||
│ └── main.py
|
||||
├── agent/ # Python-агент
|
||||
├── agent/ # Python-агент (ручная установка)
|
||||
│ ├── agent.py
|
||||
│ ├── config.json
|
||||
│ └── install.sh
|
||||
├── agent-deb/ # Исходники .deb пакета (dpkg-deb --build)
|
||||
├── deploy/ # Массовый деплой агента по SSH
|
||||
│ ├── deploy-agent.sh
|
||||
│ ├── ip.txt.example
|
||||
│ └── README.md
|
||||
├── data/ # SQLite БД (создаётся при запуске)
|
||||
├── docker-compose.yml
|
||||
├── nginx.conf
|
||||
├── info-user-agent_*.deb
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -75,7 +88,7 @@ http://localhost
|
||||
|
||||
```bash
|
||||
# Установить .deb пакет
|
||||
sudo dpkg -i info-user-agent_1.0.0_all.deb
|
||||
sudo dpkg -i info-user-agent_1.0.3_all.deb
|
||||
|
||||
# Настроить сервер и токен (интерактивно)
|
||||
sudo info-user-agent-setup
|
||||
@@ -84,7 +97,7 @@ sudo info-user-agent-setup
|
||||
sudo nano /opt/info-user-agent/config.json
|
||||
```
|
||||
|
||||
Либо через репозиторий (добавив его в `/etc/apt/sources.list.d/`).
|
||||
Либо через собственный apt-репозиторий, если он настроен на сервере.
|
||||
|
||||
### Вручную (любой Linux)
|
||||
|
||||
@@ -95,6 +108,18 @@ sudo bash install.sh
|
||||
|
||||
Не забудьте отредактировать `agent/config.json`, указав `server_url` и `agent_token`.
|
||||
|
||||
### Массовый деплой на несколько ПК
|
||||
|
||||
Для установки/обновления агента сразу на множестве Linux-хостов используйте скрипт в папке `deploy/`:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
# Подготовьте ip.txt (пример в ip.txt.example) и положите id_rsa рядом
|
||||
./deploy-agent.sh
|
||||
```
|
||||
|
||||
Скрипт подключается по SSH (ключ или пароль), устанавливает агент через apt с fallback на `dpkg -i`, настраивает `config.json` и запускает таймер. Подробности — в [`deploy/README.md`](deploy/README.md).
|
||||
|
||||
## Настройка агента
|
||||
|
||||
Файл `agent/config.json`:
|
||||
@@ -113,10 +138,13 @@ sudo bash install.sh
|
||||
- Имя хоста, текущий пользователь, локальный IP
|
||||
- Операционная система
|
||||
- Uptime
|
||||
- Загрузка CPU и RAM
|
||||
- Загрузка CPU
|
||||
- RAM: процент, использовано/доступно/всего в GB
|
||||
- Swap: процент, использовано/свободно/всего в GB
|
||||
- Информация по дискам
|
||||
- Температура CPU (если доступна)
|
||||
- Top-10 процессов по CPU
|
||||
- Top-10 процессов по CPU/RAM
|
||||
- Активные пользовательские сессии (`who -u`)
|
||||
|
||||
## API
|
||||
|
||||
@@ -124,11 +152,31 @@ sudo bash install.sh
|
||||
- `GET /api/computers` — список компьютеров (с фильтрами `status`, `user`, `search`, `sort`, `order`)
|
||||
- `GET /api/computers/export/csv` — экспорт в CSV
|
||||
- `GET /api/computers/{id}` — детали ПК с историей
|
||||
- `DELETE /api/computers/{id}` — удалить ПК (admin)
|
||||
- `GET /api/notifications` — уведомления
|
||||
- `POST /api/notifications/{id}/read` — отметить прочитанным
|
||||
- `POST /api/notifications/read-all` — отметить все прочитанными
|
||||
- `GET /api/audit` — аудит-лог (admin)
|
||||
- `GET /api/settings` — runtime-настройки (admin)
|
||||
- `POST /api/settings` — изменить runtime-настройки (admin)
|
||||
- `POST /api/auth/login` — вход в веб-интерфейс
|
||||
- `POST /api/auth/register` — регистрация нового пользователя (viewer)
|
||||
- `POST /api/auth/register` — регистрация нового пользователя (только если ещё нет admin)
|
||||
- `GET /api/auth/users` — список пользователей (admin)
|
||||
- `POST /api/auth/users/{id}/role` — сменить роль (admin)
|
||||
- `DELETE /api/auth/users/{id}` — удалить пользователя (admin)
|
||||
|
||||
## Runtime-настройки
|
||||
|
||||
Администратор может менять настройки прямо в веб-интерфейсе (`/admin/settings`):
|
||||
|
||||
- `AGENT_TOKEN` — токен для агентов
|
||||
- `OFFLINE_THRESHOLD_MINUTES` — через сколько минут без heartbeat ПК считается офлайн
|
||||
- `NOTIFICATION_WEBHOOK_URL` — URL для webhook-уведомлений
|
||||
- `NOTIFICATION_TELEGRAM_BOT_TOKEN` — токен Telegram-бота
|
||||
- `NOTIFICATION_TELEGRAM_CHAT_ID` — ID чата для Telegram
|
||||
- `TIMEZONE` — часовой пояс для отображения времени в веб-интерфейсе
|
||||
|
||||
Значения из БД переопределяют `.env` во время работы сервера.
|
||||
|
||||
## Уведомления об офлайн-ПК
|
||||
|
||||
@@ -159,6 +207,13 @@ sudo bash install.sh
|
||||
- [X] Уведомления об офлайн-ПК (in-app + webhook + Telegram)
|
||||
- [X] Экспорт в CSV
|
||||
- [X] JWT-авторизация
|
||||
- [X] Ролевая модель admin/viewer
|
||||
- [X] Аудит-лог
|
||||
- [X] Runtime-настройки через веб
|
||||
- [X] Активные пользовательские сессии
|
||||
- [X] RAM и swap в GB/%
|
||||
- [X] График swap
|
||||
- [X] Часовой пояс в веб-интерфейсе
|
||||
- [ ] Email-уведомления (SMTP)
|
||||
- [ ] Интеграция с Telegram-ботом (двусторонняя)
|
||||
- [ ] Ролевая модель для уведомлений (какие роли получают какие типы уведомлений)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
Package: info-user-agent
|
||||
Version: 1.0.0
|
||||
Version: 1.0.3
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Architecture: all
|
||||
Depends: python3, python3-psutil, python3-requests
|
||||
Maintainer: Hidosi <hidosi@hidosi.ru>
|
||||
Description: InfoUser Monitor - агент мониторинга ПК
|
||||
Агент собирает метрики компьютера (CPU, RAM, диски, процессы, пользователь, IP)
|
||||
и отправляет их раз в час на сервер InfoUser Monitor.
|
||||
Агент собирает метрики компьютера (CPU, RAM, диски, процессы, активные
|
||||
пользовательские сессии, IP) и отправляет их раз в час на сервер
|
||||
InfoUser Monitor.
|
||||
.
|
||||
После установки запустите:
|
||||
sudo info-user-agent-setup
|
||||
|
||||
@@ -7,6 +7,7 @@ import getpass
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -70,6 +71,62 @@ def get_disk_info():
|
||||
return disks
|
||||
|
||||
|
||||
def get_active_sessions():
|
||||
"""Return active user sessions similar to the `who` command.
|
||||
|
||||
Each session is a dict with keys: username, tty, login_time, origin.
|
||||
Falls back to the current process user if `who` is unavailable.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["who", "-u"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
sessions = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
username = parts[0]
|
||||
tty = parts[1]
|
||||
# login time is usually "YYYY-MM-DD HH:MM" (parts[2] and parts[3])
|
||||
login_time = " ".join(parts[2:4]) if len(parts) >= 4 else ""
|
||||
# The last field may be a comment in parentheses, e.g. (192.168.255.50)
|
||||
origin = ""
|
||||
if parts[-1].startswith("(") and parts[-1].endswith(")"):
|
||||
origin = parts[-1][1:-1]
|
||||
sessions.append({
|
||||
"username": username,
|
||||
"tty": tty,
|
||||
"login_time": login_time,
|
||||
"origin": origin,
|
||||
})
|
||||
return sessions
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_display_username(sessions):
|
||||
"""Return a short username representation for the dashboard.
|
||||
|
||||
Single user -> username
|
||||
Multiple -> comma-separated usernames
|
||||
No sessions -> fallback to process user
|
||||
"""
|
||||
usernames = [s["username"] for s in sessions if s.get("username")]
|
||||
if not usernames:
|
||||
return getpass.getuser()
|
||||
return ", ".join(dict.fromkeys(usernames))
|
||||
|
||||
|
||||
def get_cpu_temp():
|
||||
try:
|
||||
temps = psutil.sensors_temperatures()
|
||||
@@ -90,15 +147,31 @@ def get_load_avg():
|
||||
return None
|
||||
|
||||
|
||||
def get_top_processes(limit=10):
|
||||
def get_top_processes(limit=10, processes=None):
|
||||
"""Return top processes by CPU usage.
|
||||
|
||||
If ``processes`` is provided, it should be a list of psutil.Process objects
|
||||
whose CPU baseline has already been established (cpu_percent called once).
|
||||
The actual CPU usage is measured with the next call to cpu_percent().
|
||||
"""
|
||||
if processes is None:
|
||||
processes = []
|
||||
for p in psutil.process_iter(["pid", "name"]):
|
||||
try:
|
||||
p.cpu_percent(interval=None)
|
||||
processes.append(p)
|
||||
except Exception:
|
||||
continue
|
||||
time.sleep(1)
|
||||
|
||||
procs = []
|
||||
for p in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]):
|
||||
for p in processes:
|
||||
try:
|
||||
procs.append({
|
||||
"pid": p.info["pid"],
|
||||
"name": p.info["name"],
|
||||
"cpu_percent": round(p.info["cpu_percent"] or 0, 1),
|
||||
"ram_percent": round((p.info["memory_percent"] or 0), 1),
|
||||
"pid": p.pid,
|
||||
"name": p.name(),
|
||||
"cpu_percent": round(p.cpu_percent(interval=None), 1),
|
||||
"ram_percent": round(p.memory_percent(), 1),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
@@ -108,20 +181,43 @@ def get_top_processes(limit=10):
|
||||
|
||||
def collect():
|
||||
cfg = load_config()
|
||||
sessions = get_active_sessions()
|
||||
|
||||
# Establish per-process CPU baseline first. The 1-second interval below for
|
||||
# system CPU also gives us meaningful per-process CPU percentages without
|
||||
# an extra delay.
|
||||
baseline_procs = []
|
||||
for p in psutil.process_iter(["pid", "name"]):
|
||||
try:
|
||||
p.cpu_percent(interval=None)
|
||||
baseline_procs.append(p)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
sm = psutil.swap_memory()
|
||||
|
||||
payload = {
|
||||
"hostname": socket.gethostname(),
|
||||
"username": getpass.getuser(),
|
||||
"username": get_display_username(sessions),
|
||||
"sessions": sessions,
|
||||
"os_info": f"{platform.system()} {platform.release()}",
|
||||
"local_ip": get_local_ip(),
|
||||
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
|
||||
"uptime_sec": get_uptime(),
|
||||
"cpu_percent": psutil.cpu_percent(interval=1),
|
||||
"ram_percent": psutil.virtual_memory().percent,
|
||||
"ram_total_gb": round(psutil.virtual_memory().total / (2**30), 1),
|
||||
"ram_percent": vm.percent,
|
||||
"ram_total_gb": round(vm.total / (2**30), 1),
|
||||
"ram_used_gb": round(vm.used / (2**30), 1),
|
||||
"ram_available_gb": round(vm.available / (2**30), 1),
|
||||
"swap_total_gb": round(sm.total / (2**30), 1),
|
||||
"swap_used_gb": round(sm.used / (2**30), 1),
|
||||
"swap_free_gb": round(sm.free / (2**30), 1),
|
||||
"swap_percent": sm.percent,
|
||||
"disk_info": get_disk_info(),
|
||||
"cpu_temp": get_cpu_temp(),
|
||||
"load_avg": get_load_avg(),
|
||||
"processes": get_top_processes(10),
|
||||
"processes": get_top_processes(10, baseline_procs),
|
||||
}
|
||||
return payload, cfg
|
||||
|
||||
|
||||
+106
-10
@@ -7,6 +7,7 @@ import getpass
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -70,6 +71,62 @@ def get_disk_info():
|
||||
return disks
|
||||
|
||||
|
||||
def get_active_sessions():
|
||||
"""Return active user sessions similar to the `who` command.
|
||||
|
||||
Each session is a dict with keys: username, tty, login_time, origin.
|
||||
Falls back to the current process user if `who` is unavailable.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["who", "-u"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
sessions = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
username = parts[0]
|
||||
tty = parts[1]
|
||||
# login time is usually "YYYY-MM-DD HH:MM" (parts[2] and parts[3])
|
||||
login_time = " ".join(parts[2:4]) if len(parts) >= 4 else ""
|
||||
# The last field may be a comment in parentheses, e.g. (192.168.255.50)
|
||||
origin = ""
|
||||
if parts[-1].startswith("(") and parts[-1].endswith(")"):
|
||||
origin = parts[-1][1:-1]
|
||||
sessions.append({
|
||||
"username": username,
|
||||
"tty": tty,
|
||||
"login_time": login_time,
|
||||
"origin": origin,
|
||||
})
|
||||
return sessions
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_display_username(sessions):
|
||||
"""Return a short username representation for the dashboard.
|
||||
|
||||
Single user -> username
|
||||
Multiple -> comma-separated usernames
|
||||
No sessions -> fallback to process user
|
||||
"""
|
||||
usernames = [s["username"] for s in sessions if s.get("username")]
|
||||
if not usernames:
|
||||
return getpass.getuser()
|
||||
return ", ".join(dict.fromkeys(usernames))
|
||||
|
||||
|
||||
def get_cpu_temp():
|
||||
try:
|
||||
temps = psutil.sensors_temperatures()
|
||||
@@ -90,15 +147,31 @@ def get_load_avg():
|
||||
return None
|
||||
|
||||
|
||||
def get_top_processes(limit=10):
|
||||
def get_top_processes(limit=10, processes=None):
|
||||
"""Return top processes by CPU usage.
|
||||
|
||||
If ``processes`` is provided, it should be a list of psutil.Process objects
|
||||
whose CPU baseline has already been established (cpu_percent called once).
|
||||
The actual CPU usage is measured with the next call to cpu_percent().
|
||||
"""
|
||||
if processes is None:
|
||||
processes = []
|
||||
for p in psutil.process_iter(["pid", "name"]):
|
||||
try:
|
||||
p.cpu_percent(interval=None)
|
||||
processes.append(p)
|
||||
except Exception:
|
||||
continue
|
||||
time.sleep(1)
|
||||
|
||||
procs = []
|
||||
for p in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]):
|
||||
for p in processes:
|
||||
try:
|
||||
procs.append({
|
||||
"pid": p.info["pid"],
|
||||
"name": p.info["name"],
|
||||
"cpu_percent": round(p.info["cpu_percent"] or 0, 1),
|
||||
"ram_percent": round((p.info["memory_percent"] or 0), 1),
|
||||
"pid": p.pid,
|
||||
"name": p.name(),
|
||||
"cpu_percent": round(p.cpu_percent(interval=None), 1),
|
||||
"ram_percent": round(p.memory_percent(), 1),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
@@ -108,20 +181,43 @@ def get_top_processes(limit=10):
|
||||
|
||||
def collect():
|
||||
cfg = load_config()
|
||||
sessions = get_active_sessions()
|
||||
|
||||
# Establish per-process CPU baseline first. The 1-second interval below for
|
||||
# system CPU also gives us meaningful per-process CPU percentages without
|
||||
# an extra delay.
|
||||
baseline_procs = []
|
||||
for p in psutil.process_iter(["pid", "name"]):
|
||||
try:
|
||||
p.cpu_percent(interval=None)
|
||||
baseline_procs.append(p)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
sm = psutil.swap_memory()
|
||||
|
||||
payload = {
|
||||
"hostname": socket.gethostname(),
|
||||
"username": getpass.getuser(),
|
||||
"username": get_display_username(sessions),
|
||||
"sessions": sessions,
|
||||
"os_info": f"{platform.system()} {platform.release()}",
|
||||
"local_ip": get_local_ip(),
|
||||
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
|
||||
"uptime_sec": get_uptime(),
|
||||
"cpu_percent": psutil.cpu_percent(interval=1),
|
||||
"ram_percent": psutil.virtual_memory().percent,
|
||||
"ram_total_gb": round(psutil.virtual_memory().total / (2**30), 1),
|
||||
"ram_percent": vm.percent,
|
||||
"ram_total_gb": round(vm.total / (2**30), 1),
|
||||
"ram_used_gb": round(vm.used / (2**30), 1),
|
||||
"ram_available_gb": round(vm.available / (2**30), 1),
|
||||
"swap_total_gb": round(sm.total / (2**30), 1),
|
||||
"swap_used_gb": round(sm.used / (2**30), 1),
|
||||
"swap_free_gb": round(sm.free / (2**30), 1),
|
||||
"swap_percent": sm.percent,
|
||||
"disk_info": get_disk_info(),
|
||||
"cpu_temp": get_cpu_temp(),
|
||||
"load_avg": get_load_avg(),
|
||||
"processes": get_top_processes(10),
|
||||
"processes": get_top_processes(10, baseline_procs),
|
||||
}
|
||||
return payload, cfg
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Массовый деплой InfoUser Agent
|
||||
|
||||
Скрипт `deploy-agent.sh` устанавливает или обновляет агент `info-user-agent` на списке Linux-хостов по SSH.
|
||||
|
||||
## Принцип работы
|
||||
|
||||
1. Пытается подключиться по ключу `id_rsa`.
|
||||
2. Если ключ не подходит — подключается по паролю и копирует публичный ключ в `~/.ssh/authorized_keys` (последующие запуски будут по ключу).
|
||||
3. Устанавливает/обновляет агент:
|
||||
- Сначала `apt-get install info-user-agent` из подключённого apt-репозитория.
|
||||
- Если apt не сработал — fallback через `dpkg -i` из `.deb` в корне проекта.
|
||||
4. Пишет `config.json` с адресом сервера и токеном.
|
||||
5. Запускает systemd-таймер и триггерит первый heartbeat.
|
||||
6. Раскладывает результат в `deploy-ok.txt` / `deploy-deferred.txt` и логи в `logs/`.
|
||||
|
||||
## Требования
|
||||
|
||||
На машине, с которой запускается скрипт:
|
||||
|
||||
- `bash`
|
||||
- `ssh`, `scp`
|
||||
- `sshpass`
|
||||
- `ping`
|
||||
|
||||
На целевых хостах:
|
||||
|
||||
- Доступен SSH-сервер.
|
||||
- Пользователь входит в `sudo` (без пароля на сам sudo запросы идут с паролем).
|
||||
- Подключён apt-репозиторий с пакетом `info-user-agent` (для основного пути установки).
|
||||
|
||||
## Подготовка
|
||||
|
||||
### 1. SSH-ключ
|
||||
|
||||
Положите приватный ключ рядом со скриптом:
|
||||
|
||||
```bash
|
||||
cp /path/to/id_rsa ./id_rsa
|
||||
chmod 600 ./id_rsa
|
||||
```
|
||||
|
||||
Публичный ключ `./id_rsa.pub` будет сгенерирован автоматически при первом запуске.
|
||||
|
||||
### 2. Список хостов
|
||||
|
||||
Создайте `ip.txt` (один хост на строку):
|
||||
|
||||
```text
|
||||
user@192.168.255.50
|
||||
user@192.168.255.16
|
||||
192.168.255.71
|
||||
# это комментарий
|
||||
```
|
||||
|
||||
Если пользователь не указан, используется `user`.
|
||||
|
||||
### 3. Параметры подключения
|
||||
|
||||
По умолчанию:
|
||||
|
||||
- SSH-порт: `22`
|
||||
- Пользователь по умолчанию: `user`
|
||||
- Адрес сервера: `http://192.168.255.180:3000`
|
||||
- Токен агента: `a916c8cd656b3f40acc2920820abf88e`
|
||||
|
||||
**Пароль SSH/Sudo запрашивается интерактивно** при запуске скрипта. Чтобы не вводить каждый раз, можно задать переменную окружения:
|
||||
|
||||
```bash
|
||||
export SSH_PASS='1q2w3e$R'
|
||||
export SERVER_URL='http://monitor.example.com:3000'
|
||||
export AGENT_TOKEN='my-token'
|
||||
export SSH_PORT=2222
|
||||
export DEFAULT_USER='admin'
|
||||
./deploy-agent.sh
|
||||
```
|
||||
|
||||
В этом случае пароль не отображается на экране и не сохраняется в истории shell.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd /home/user/moder/info-user/deploy
|
||||
./deploy-agent.sh
|
||||
```
|
||||
|
||||
## Результат
|
||||
|
||||
После завершения рядом со скриптом появятся:
|
||||
|
||||
- `deploy-ok.txt` — хосты, на которых всё прошло успешно.
|
||||
- `deploy-deferred.txt` — хосты, которые надо доразворачивать (нет пинга, нет SSH, ошибка установки).
|
||||
- `logs/` — подробные логи по каждому хосту.
|
||||
- `ip.txt.bak.YYYYMMDD_HHMMSS` — бэкап списка хостов.
|
||||
|
||||
Если в `deploy-deferred.txt` что-то есть, исправьте проблему и запустите скрипт снова — он переберёт весь список заново.
|
||||
|
||||
## Установка отдельного хоста вручную
|
||||
|
||||
Если хост не попадает под массовый деплой:
|
||||
|
||||
```bash
|
||||
# На хосте
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y info-user-agent
|
||||
sudo info-user-agent-setup
|
||||
# или отредактируйте /opt/info-user-agent/config.json вручную
|
||||
sudo systemctl start info-user-agent.timer
|
||||
sudo systemctl start info-user-agent
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"SSH недоступен (ключ и пароль)"** — хост пингуется, но не принимает ни ключ, ни пароль. Проверьте пользователя/пароль, настройки SSH (`PermitRootLogin` / `PasswordAuthentication`).
|
||||
- **"Ошибка установки"** — apt-репозиторий недоступен и fallback `.deb` не найден. Положите актуальный `.deb` в корень проекта (`info-user-agent_*.deb`).
|
||||
- **sshpass не найден** — установите:
|
||||
```bash
|
||||
sudo apt-get install -y sshpass
|
||||
```
|
||||
Executable
+395
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy-agent.sh — массовая установка/обновление InfoUser Agent.
|
||||
#
|
||||
# Использование:
|
||||
# cd /home/user/moder/info-user/deploy
|
||||
# ./deploy-agent.sh
|
||||
#
|
||||
# Перед запуском:
|
||||
# 1. Положить id_rsa в ./id_rsa
|
||||
# 2. Создать ./ip.txt со списком хостов (user@IP или просто IP)
|
||||
# 3. При необходимости изменить SERVER_URL/AGENT_TOKEN ниже или через env
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
HOSTS_FILE="${SCRIPT_DIR}/ip.txt"
|
||||
KEY_FILE="${SCRIPT_DIR}/id_rsa"
|
||||
PUB_FILE="${SCRIPT_DIR}/id_rsa.pub"
|
||||
|
||||
OK_FILE="${SCRIPT_DIR}/deploy-ok.txt"
|
||||
DEFER_FILE="${SCRIPT_DIR}/deploy-deferred.txt"
|
||||
LOG_DIR="${SCRIPT_DIR}/logs"
|
||||
|
||||
PKG_NAME="info-user-agent"
|
||||
AGENT_CONFIG_DIR="/opt/info-user-agent"
|
||||
AGENT_CONFIG_FILE="${AGENT_CONFIG_DIR}/config.json"
|
||||
|
||||
SERVER_URL="${SERVER_URL:-http://192.168.255.180:3000}"
|
||||
AGENT_TOKEN="${AGENT_TOKEN:-a916c8cd656b3f40acc2920820abf88e}"
|
||||
SSH_PORT="${SSH_PORT:-22}"
|
||||
DEFAULT_USER="${DEFAULT_USER:-user}"
|
||||
MAX_RETRIES="${MAX_RETRIES:-3}"
|
||||
RETRY_SLEEP="${RETRY_SLEEP:-3}"
|
||||
|
||||
# Пароль SSH/Sudo запрашивается интерактивно, если не задан через env.
|
||||
# Пример: SSH_PASS='MyPass' ./deploy-agent.sh
|
||||
if [[ -z "${SSH_PASS:-}" ]]; then
|
||||
read -rsp "SSH/Sudo пароль: " SSH_PASS
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Цвета
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
SSH_KEY_OPTS=(
|
||||
-o BatchMode=yes
|
||||
-o StrictHostKeyChecking=accept-new
|
||||
-o ConnectTimeout=6
|
||||
-o ConnectionAttempts=1
|
||||
-o ServerAliveInterval=5
|
||||
-o ServerAliveCountMax=1
|
||||
-o PasswordAuthentication=no
|
||||
-p "$SSH_PORT"
|
||||
)
|
||||
|
||||
SSH_PASS_OPTS=(
|
||||
-o StrictHostKeyChecking=accept-new
|
||||
-o ConnectTimeout=6
|
||||
-o ConnectionAttempts=1
|
||||
-o ServerAliveInterval=5
|
||||
-o ServerAliveCountMax=1
|
||||
-p "$SSH_PORT"
|
||||
)
|
||||
|
||||
SCP_KEY_OPTS=(
|
||||
-i "$KEY_FILE"
|
||||
-o BatchMode=yes
|
||||
-o StrictHostKeyChecking=accept-new
|
||||
-o ConnectTimeout=6
|
||||
-o ConnectionAttempts=1
|
||||
-p "$SSH_PORT"
|
||||
)
|
||||
|
||||
SCP_PASS_OPTS=(
|
||||
-o StrictHostKeyChecking=accept-new
|
||||
-o ConnectTimeout=6
|
||||
-o ConnectionAttempts=1
|
||||
-p "$SSH_PORT"
|
||||
)
|
||||
|
||||
print_header() {
|
||||
echo -e "${BLUE}=== InfoUser Agent deploy ===${NC}"
|
||||
echo "SERVER_URL: $SERVER_URL"
|
||||
echo "AGENT_TOKEN: ${AGENT_TOKEN:0:8}..."
|
||||
echo "HOSTS_FILE: $HOSTS_FILE"
|
||||
echo "KEY_FILE: $KEY_FILE"
|
||||
echo "LOG_DIR: $LOG_DIR"
|
||||
echo ""
|
||||
}
|
||||
|
||||
check_prerequisites() {
|
||||
local missing=()
|
||||
command -v ssh >/dev/null 2>&1 || missing+=("ssh")
|
||||
command -v scp >/dev/null 2>&1 || missing+=("scp")
|
||||
command -v sshpass >/dev/null 2>&1 || missing+=("sshpass")
|
||||
command -v ping >/dev/null 2>&1 || missing+=("ping")
|
||||
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
echo -e "${RED}Ошибка: не найдены утилиты: ${missing[*]}${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HOSTS_FILE" ]]; then
|
||||
echo -e "${RED}Ошибка: файл со списком хостов не найден: $HOSTS_FILE${NC}" >&2
|
||||
echo "Создайте его по примеру ip.txt.example" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$KEY_FILE" ]]; then
|
||||
echo -e "${RED}Ошибка: SSH-ключ не найден: $KEY_FILE${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Генерируем .pub если нет
|
||||
if [[ ! -f "$PUB_FILE" ]]; then
|
||||
echo "Генерирую публичный ключ: $PUB_FILE"
|
||||
ssh-keygen -y -f "$KEY_FILE" > "$PUB_FILE"
|
||||
fi
|
||||
|
||||
# Ищем fallback .deb в корне проекта
|
||||
DEB_FILE=""
|
||||
for f in "${PROJECT_DIR}"/${PKG_NAME}_*_all.deb; do
|
||||
[[ -f "$f" ]] && DEB_FILE="$f"
|
||||
done
|
||||
|
||||
if [[ -z "$DEB_FILE" ]]; then
|
||||
echo -e "${YELLOW}Предупреждение: fallback .deb не найден в $PROJECT_DIR. Будет использован только apt.${NC}"
|
||||
else
|
||||
echo "Fallback .deb: $DEB_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_target() {
|
||||
local line="$1"
|
||||
if [[ "$line" == *"@"* ]]; then
|
||||
echo "$line"
|
||||
else
|
||||
echo "${DEFAULT_USER}@${line}"
|
||||
fi
|
||||
}
|
||||
|
||||
extract_host() {
|
||||
local target="$1"
|
||||
echo "${target#*@}"
|
||||
}
|
||||
|
||||
safe_name() {
|
||||
local target="$1"
|
||||
echo "${target//[@.:\/]/_}"
|
||||
}
|
||||
|
||||
with_retries() {
|
||||
local desc="$1"
|
||||
shift
|
||||
local attempt=1
|
||||
while true; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
if (( attempt >= MAX_RETRIES )); then
|
||||
return 1
|
||||
fi
|
||||
echo " ${desc}: повтор ${attempt}/${MAX_RETRIES}..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep "$RETRY_SLEEP"
|
||||
done
|
||||
}
|
||||
|
||||
ping_check() {
|
||||
local host="$1"
|
||||
ping -c 1 -W 1 "$host" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ssh_key_check() {
|
||||
local target="$1"
|
||||
ssh -i "$KEY_FILE" -n "${SSH_KEY_OPTS[@]}" "$target" "echo ok" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ssh_pass_check() {
|
||||
local target="$1"
|
||||
sshpass -p "$SSH_PASS" ssh -n "${SSH_PASS_OPTS[@]}" "$target" "echo ok" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
detect_auth_method() {
|
||||
local target="$1"
|
||||
if ssh_key_check "$target"; then
|
||||
echo "key"
|
||||
return 0
|
||||
fi
|
||||
if ssh_pass_check "$target"; then
|
||||
echo "pass"
|
||||
return 0
|
||||
fi
|
||||
echo "fail"
|
||||
}
|
||||
|
||||
install_key_on_host() {
|
||||
local target="$1"
|
||||
local log_file="$2"
|
||||
{
|
||||
echo "==> SSH по паролю успешен, добавляю ключ в authorized_keys"
|
||||
sshpass -p "$SSH_PASS" ssh -n "${SSH_PASS_OPTS[@]}" "$target" "mkdir -p ~/.ssh && chmod 700 ~/.ssh"
|
||||
sshpass -p "$SSH_PASS" scp "${SCP_PASS_OPTS[@]}" "$PUB_FILE" "$target:/tmp/info_user_key.pub"
|
||||
sshpass -p "$SSH_PASS" ssh -n "${SSH_PASS_OPTS[@]}" "$target" "cat /tmp/info_user_key.pub >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && rm -f /tmp/info_user_key.pub"
|
||||
} >>"$log_file" 2>&1 || true
|
||||
}
|
||||
|
||||
ssh_exec() {
|
||||
local method="$1"
|
||||
local target="$2"
|
||||
local cmd="$3"
|
||||
if [[ "$method" == "key" ]]; then
|
||||
ssh -i "$KEY_FILE" -n "${SSH_KEY_OPTS[@]}" "$target" "$cmd"
|
||||
else
|
||||
sshpass -p "$SSH_PASS" ssh -n "${SSH_PASS_OPTS[@]}" "$target" "$cmd"
|
||||
fi
|
||||
}
|
||||
|
||||
scp_to_host() {
|
||||
local method="$1"
|
||||
local target="$2"
|
||||
local local_path="$3"
|
||||
local remote_path="$4"
|
||||
if [[ "$method" == "key" ]]; then
|
||||
scp "${SCP_KEY_OPTS[@]}" "$local_path" "$target:$remote_path"
|
||||
else
|
||||
sshpass -p "$SSH_PASS" scp "${SCP_PASS_OPTS[@]}" "$local_path" "$target:$remote_path"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_agent() {
|
||||
local method="$1"
|
||||
local target="$2"
|
||||
local log_file="$3"
|
||||
|
||||
local config_json
|
||||
config_json=$(printf '{"server_url":"%s","agent_token":"%s","interval_minutes":60,"send_public_ip":false}' "$SERVER_URL" "$AGENT_TOKEN")
|
||||
|
||||
local remote_cmd
|
||||
remote_cmd=$(printf 'echo "%s" | sudo -S tee %s >/dev/null' "$config_json" "$AGENT_CONFIG_FILE")
|
||||
|
||||
{
|
||||
echo "==> Настраиваю $AGENT_CONFIG_FILE"
|
||||
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S mkdir -p '$AGENT_CONFIG_DIR'"
|
||||
ssh_exec "$method" "$target" "$remote_cmd"
|
||||
|
||||
echo "==> Запускаю таймер и первый heartbeat"
|
||||
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S systemctl daemon-reload"
|
||||
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S systemctl enable info-user-agent.timer"
|
||||
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S systemctl start info-user-agent.timer"
|
||||
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S systemctl start info-user-agent"
|
||||
} >>"$log_file" 2>&1
|
||||
}
|
||||
|
||||
install_agent() {
|
||||
local method="$1"
|
||||
local target="$2"
|
||||
local log_file="$3"
|
||||
|
||||
# Сначала пробуем apt
|
||||
{
|
||||
echo "==> Пробую установить через apt"
|
||||
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S apt-get update -qq"
|
||||
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S apt-get install -y '$PKG_NAME'"
|
||||
} >>"$log_file" 2>&1 && return 0
|
||||
|
||||
# Fallback на dpkg если apt не сработал и есть .deb
|
||||
if [[ -z "$DEB_FILE" ]]; then
|
||||
echo " apt не сработал, fallback .deb отсутствует" >>"$log_file" 2>&1
|
||||
return 1
|
||||
fi
|
||||
|
||||
local deb_name
|
||||
deb_name="$(basename "$DEB_FILE")"
|
||||
local remote_deb="/tmp/${deb_name}"
|
||||
|
||||
{
|
||||
echo "==> apt не сработал, пробую dpkg -i $deb_name"
|
||||
scp_to_host "$method" "$target" "$DEB_FILE" "$remote_deb"
|
||||
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S dpkg -i '$remote_deb'"
|
||||
ssh_exec "$method" "$target" "rm -f '$remote_deb'"
|
||||
} >>"$log_file" 2>&1
|
||||
}
|
||||
|
||||
process_host() {
|
||||
local raw_line="$1"
|
||||
local target
|
||||
local host_only
|
||||
local safe_host
|
||||
local log_file
|
||||
local auth_method
|
||||
|
||||
target="$(normalize_target "$raw_line")"
|
||||
host_only="$(extract_host "$target")"
|
||||
safe_host="$(safe_name "$target")"
|
||||
log_file="${LOG_DIR}/${safe_host}.log"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}===== $target =====${NC}"
|
||||
|
||||
# Пинг
|
||||
if ! with_retries "ping" ping_check "$host_only"; then
|
||||
echo -e "${RED}❌ Пинг не проходит: $target -> отложен${NC}"
|
||||
echo "$target" >> "$DEFER_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Определяем метод авторизации
|
||||
auth_method="$(detect_auth_method "$target")"
|
||||
if [[ "$auth_method" == "fail" ]]; then
|
||||
echo -e "${RED}❌ SSH недоступен (ключ и пароль): $target -> отложен${NC}"
|
||||
echo "$target" >> "$DEFER_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$auth_method" == "key" ]]; then
|
||||
echo -e "${GREEN}✅ SSH по ключу: $target${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✅ SSH по паролю: $target (добавляю ключ)${NC}"
|
||||
install_key_on_host "$target" "$log_file"
|
||||
fi
|
||||
|
||||
# Установка/обновление
|
||||
if install_agent "$auth_method" "$target" "$log_file"; then
|
||||
echo -e "${GREEN}✅ Агент установлен/обновлён: $target${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Ошибка установки: $target -> отложен (см. $log_file)${NC}"
|
||||
echo "$target" >> "$DEFER_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Настройка и запуск
|
||||
if configure_agent "$auth_method" "$target" "$log_file"; then
|
||||
echo -e "${GREEN}✅ Настроен и запущен: $target${NC}"
|
||||
echo "$target" >> "$OK_FILE"
|
||||
else
|
||||
echo -e "${RED}❌ Установлен, но не удалось настроить/запустить: $target -> отложен (см. $log_file)${NC}"
|
||||
echo "$target" >> "$DEFER_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
local ok_count
|
||||
local defer_count
|
||||
ok_count="$(wc -l < "$OK_FILE" 2>/dev/null || echo 0)"
|
||||
defer_count="$(wc -l < "$DEFER_FILE" 2>/dev/null || echo 0)"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}=== Итог ===${NC}"
|
||||
echo -e "${GREEN}Успешно: $ok_count${NC}"
|
||||
echo -e "${RED}Отложено: $defer_count${NC}"
|
||||
echo "Логи: $LOG_DIR/"
|
||||
if [[ -s "$DEFER_FILE" ]]; then
|
||||
echo "Повторить для отложенных: ./deploy-agent.sh"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
print_header
|
||||
check_prerequisites
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
: > "$OK_FILE"
|
||||
: > "$DEFER_FILE"
|
||||
|
||||
# Бэкап ip.txt
|
||||
cp -f "$HOSTS_FILE" "${HOSTS_FILE}.bak.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true
|
||||
|
||||
local total=0
|
||||
total="$(grep -vE '^\s*$|^\s*#' "$HOSTS_FILE" | wc -l)"
|
||||
echo -e "${BLUE}Хостов в списке: $total${NC}"
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Убираем CR (Windows-концы строк)
|
||||
line="${line//$'\r'/}"
|
||||
|
||||
# trim
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
|
||||
[[ -z "$line" || "$line" =~ ^# ]] && continue
|
||||
process_host "$line"
|
||||
done < "$HOSTS_FILE"
|
||||
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Список хостов для массового деплоя InfoUser Agent.
|
||||
# Формат: user@IP или просто IP (будет использован DEFAULT_USER).
|
||||
# Пустые строки и строки, начинающиеся с #, игнорируются.
|
||||
|
||||
user@192.168.255.50
|
||||
user@192.168.255.16
|
||||
192.168.255.71
|
||||
Binary file not shown.
Binary file not shown.
+18
-1
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from config import get_settings
|
||||
@@ -50,9 +50,12 @@ def verify_token(token: str, token_type: str) -> Optional[dict]:
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Optional[models.User]:
|
||||
if not token:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -74,6 +77,20 @@ def require_admin(user: models.User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
|
||||
def log_action(db: Session, user: Optional[models.User], action: str, details: Optional[str] = None):
|
||||
try:
|
||||
entry = models.AuditLog(
|
||||
user_id=user.id if user else None,
|
||||
username=user.username if user else None,
|
||||
action=action,
|
||||
details=details,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
|
||||
def create_default_admin(db: Session) -> Optional[models.User]:
|
||||
if not settings.CREATE_ADMIN_ON_STARTUP:
|
||||
return None
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -24,3 +24,52 @@ def get_db():
|
||||
|
||||
def create_tables():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""Apply lightweight schema migrations for SQLite."""
|
||||
from sqlalchemy import inspect, text
|
||||
insp = inspect(engine)
|
||||
|
||||
# AuditLog: add username column if missing
|
||||
if "audit_log" in insp.get_table_names():
|
||||
audit_cols = {c["name"] for c in insp.get_columns("audit_log")}
|
||||
if "username" not in audit_cols:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE audit_log ADD COLUMN username TEXT"))
|
||||
conn.commit()
|
||||
|
||||
# Heartbeat: add missing columns
|
||||
if "heartbeats" in insp.get_table_names():
|
||||
hb_cols = {c["name"] for c in insp.get_columns("heartbeats")}
|
||||
new_hb_cols = [
|
||||
("sessions", "TEXT"),
|
||||
("ram_used_gb", "REAL"),
|
||||
("ram_available_gb", "REAL"),
|
||||
("swap_total_gb", "REAL"),
|
||||
("swap_used_gb", "REAL"),
|
||||
("swap_free_gb", "REAL"),
|
||||
("swap_percent", "REAL"),
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
for col, col_type in new_hb_cols:
|
||||
if col not in hb_cols:
|
||||
conn.execute(text(f"ALTER TABLE heartbeats ADD COLUMN {col} {col_type}"))
|
||||
conn.commit()
|
||||
|
||||
# Computer: add missing columns
|
||||
if "computers" in insp.get_table_names():
|
||||
pc_cols = {c["name"] for c in insp.get_columns("computers")}
|
||||
new_pc_cols = [
|
||||
("current_ram_used_gb", "REAL"),
|
||||
("current_ram_available_gb", "REAL"),
|
||||
("current_swap_total_gb", "REAL"),
|
||||
("current_swap_used_gb", "REAL"),
|
||||
("current_swap_free_gb", "REAL"),
|
||||
("current_swap_percent", "REAL"),
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
for col, col_type in new_pc_cols:
|
||||
if col not in pc_cols:
|
||||
conn.execute(text(f"ALTER TABLE computers ADD COLUMN {col} {col_type}"))
|
||||
conn.commit()
|
||||
|
||||
+21
-2
@@ -1,16 +1,19 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from database import create_tables, SessionLocal
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
create_tables()
|
||||
run_migrations()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
create_default_admin(db)
|
||||
@@ -36,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)
|
||||
|
||||
@@ -28,6 +28,12 @@ class Computer(Base):
|
||||
current_cpu_percent = Column(Float, nullable=True)
|
||||
current_ram_percent = Column(Float, nullable=True)
|
||||
current_ram_total_gb = Column(Float, nullable=True)
|
||||
current_ram_used_gb = Column(Float, nullable=True)
|
||||
current_ram_available_gb = Column(Float, nullable=True)
|
||||
current_swap_total_gb = Column(Float, nullable=True)
|
||||
current_swap_used_gb = Column(Float, nullable=True)
|
||||
current_swap_free_gb = Column(Float, nullable=True)
|
||||
current_swap_percent = Column(Float, nullable=True)
|
||||
|
||||
|
||||
class Heartbeat(Base):
|
||||
@@ -43,10 +49,17 @@ class Heartbeat(Base):
|
||||
cpu_percent = Column(Float, nullable=True)
|
||||
ram_percent = Column(Float, nullable=True)
|
||||
ram_total_gb = Column(Float, nullable=True)
|
||||
ram_used_gb = Column(Float, nullable=True)
|
||||
ram_available_gb = Column(Float, nullable=True)
|
||||
swap_total_gb = Column(Float, nullable=True)
|
||||
swap_used_gb = Column(Float, nullable=True)
|
||||
swap_free_gb = Column(Float, nullable=True)
|
||||
swap_percent = Column(Float, nullable=True)
|
||||
disk_info = Column(JSON, nullable=True)
|
||||
cpu_temp = Column(Float, nullable=True)
|
||||
load_avg = Column(String, nullable=True)
|
||||
processes = Column(JSON, nullable=True)
|
||||
sessions = Column(JSON, nullable=True)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
@@ -54,6 +67,7 @@ class AuditLog(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
username = Column(String, nullable=True)
|
||||
action = Column(String, nullable=False)
|
||||
details = Column(Text, nullable=True)
|
||||
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -71,3 +85,12 @@ class Notification(Base):
|
||||
sent_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String, unique=True, nullable=False, index=True)
|
||||
value = Column(Text, nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
+25
-9
@@ -6,11 +6,19 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from config import get_settings
|
||||
from models import Computer, Notification
|
||||
from settings_store import get_setting
|
||||
|
||||
settings = get_settings()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _int_or_default(value, default):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def create_notification(db: Session, computer: Computer, ntype: str, title: str, message: str) -> Notification:
|
||||
n = Notification(
|
||||
computer_id=computer.id,
|
||||
@@ -21,13 +29,17 @@ def create_notification(db: Session, computer: Computer, ntype: str, title: str,
|
||||
db.add(n)
|
||||
db.commit()
|
||||
db.refresh(n)
|
||||
send_external_notifications(n, computer)
|
||||
send_external_notifications(db, n, computer)
|
||||
return n
|
||||
|
||||
|
||||
def check_offline_computers(db: Session):
|
||||
"""Mark online computers that missed the threshold as offline and create notifications."""
|
||||
threshold = datetime.now(timezone.utc) - timedelta(minutes=settings.OFFLINE_THRESHOLD_MINUTES)
|
||||
threshold_minutes = _int_or_default(
|
||||
get_setting(db, "OFFLINE_THRESHOLD_MINUTES"),
|
||||
settings.OFFLINE_THRESHOLD_MINUTES,
|
||||
)
|
||||
threshold = datetime.now(timezone.utc) - timedelta(minutes=threshold_minutes)
|
||||
stale = (
|
||||
db.query(Computer)
|
||||
.filter(Computer.status == "online", Computer.last_seen < threshold)
|
||||
@@ -49,7 +61,7 @@ def check_offline_computers(db: Session):
|
||||
if not existing:
|
||||
msg = (
|
||||
f"ПК {computer.hostname} не присылал данные более "
|
||||
f"{settings.OFFLINE_THRESHOLD_MINUTES} минут. "
|
||||
f"{threshold_minutes} минут. "
|
||||
f"Последний пользователь: {computer.current_user or '-'}, "
|
||||
f"IP: {computer.current_ip or '-'}"
|
||||
)
|
||||
@@ -57,11 +69,15 @@ def check_offline_computers(db: Session):
|
||||
db.commit()
|
||||
|
||||
|
||||
def send_external_notifications(notification: Notification, computer: Computer):
|
||||
if settings.NOTIFICATION_WEBHOOK_URL:
|
||||
def send_external_notifications(db: Session, notification: Notification, computer: Computer):
|
||||
webhook_url = get_setting(db, "NOTIFICATION_WEBHOOK_URL") or settings.NOTIFICATION_WEBHOOK_URL
|
||||
telegram_token = get_setting(db, "NOTIFICATION_TELEGRAM_BOT_TOKEN") or settings.NOTIFICATION_TELEGRAM_BOT_TOKEN
|
||||
telegram_chat = get_setting(db, "NOTIFICATION_TELEGRAM_CHAT_ID") or settings.NOTIFICATION_TELEGRAM_CHAT_ID
|
||||
|
||||
if webhook_url:
|
||||
try:
|
||||
requests.post(
|
||||
settings.NOTIFICATION_WEBHOOK_URL,
|
||||
webhook_url,
|
||||
json={
|
||||
"type": notification.type,
|
||||
"title": notification.title,
|
||||
@@ -75,13 +91,13 @@ def send_external_notifications(notification: Notification, computer: Computer):
|
||||
except Exception as e:
|
||||
logger.error("Webhook notification failed: %s", e)
|
||||
|
||||
if settings.NOTIFICATION_TELEGRAM_BOT_TOKEN and settings.NOTIFICATION_TELEGRAM_CHAT_ID:
|
||||
if telegram_token and telegram_chat:
|
||||
try:
|
||||
text = f"*{notification.title}*\n\n{notification.message}"
|
||||
requests.post(
|
||||
f"https://api.telegram.org/bot{settings.NOTIFICATION_TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
f"https://api.telegram.org/bot{telegram_token}/sendMessage",
|
||||
json={
|
||||
"chat_id": settings.NOTIFICATION_TELEGRAM_CHAT_ID,
|
||||
"chat_id": telegram_chat,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
},
|
||||
|
||||
@@ -10,10 +10,20 @@ from typing import List, Optional
|
||||
|
||||
from config import get_settings
|
||||
from database import get_db
|
||||
from models import Computer, Heartbeat, Notification
|
||||
from schemas import HeartbeatPayload, ComputerOut, ComputerDetailOut, HeartbeatOut, NotificationOut
|
||||
from auth import get_current_user
|
||||
from models import Computer, Heartbeat, Notification, AuditLog
|
||||
from schemas import (
|
||||
HeartbeatPayload,
|
||||
ComputerOut,
|
||||
ComputerDetailOut,
|
||||
HeartbeatOut,
|
||||
NotificationOut,
|
||||
AuditLogOut,
|
||||
SettingOut,
|
||||
SettingsUpdate,
|
||||
)
|
||||
from auth import get_current_user, require_admin, log_action
|
||||
from notifications import check_offline_computers, mark_notification_read
|
||||
from settings_store import get_setting, set_setting, get_all_settings
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api"])
|
||||
settings = get_settings()
|
||||
@@ -33,7 +43,8 @@ def heartbeat(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
token = get_agent_token(request)
|
||||
if token != settings.AGENT_TOKEN:
|
||||
expected = get_setting(db, "AGENT_TOKEN") or settings.AGENT_TOKEN
|
||||
if token != expected:
|
||||
raise HTTPException(status_code=401, detail="Invalid agent token")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -57,6 +68,16 @@ def heartbeat(
|
||||
computer.current_cpu_percent = payload.cpu_percent
|
||||
computer.current_ram_percent = payload.ram_percent
|
||||
computer.current_ram_total_gb = payload.ram_total_gb
|
||||
computer.current_ram_used_gb = payload.ram_used_gb
|
||||
computer.current_ram_available_gb = payload.ram_available_gb
|
||||
computer.current_swap_total_gb = payload.swap_total_gb
|
||||
computer.current_swap_used_gb = payload.swap_used_gb
|
||||
computer.current_swap_free_gb = payload.swap_free_gb
|
||||
computer.current_swap_percent = payload.swap_percent
|
||||
|
||||
sessions = payload.sessions
|
||||
if sessions:
|
||||
sessions = [s.model_dump() for s in sessions]
|
||||
|
||||
heartbeat = Heartbeat(
|
||||
computer_id=computer.id,
|
||||
@@ -68,10 +89,17 @@ def heartbeat(
|
||||
cpu_percent=payload.cpu_percent,
|
||||
ram_percent=payload.ram_percent,
|
||||
ram_total_gb=payload.ram_total_gb,
|
||||
ram_used_gb=payload.ram_used_gb,
|
||||
ram_available_gb=payload.ram_available_gb,
|
||||
swap_total_gb=payload.swap_total_gb,
|
||||
swap_used_gb=payload.swap_used_gb,
|
||||
swap_free_gb=payload.swap_free_gb,
|
||||
swap_percent=payload.swap_percent,
|
||||
disk_info=payload.disk_info or [],
|
||||
cpu_temp=payload.cpu_temp,
|
||||
load_avg=payload.load_avg,
|
||||
processes=payload.processes or [],
|
||||
sessions=sessions,
|
||||
)
|
||||
db.add(heartbeat)
|
||||
db.commit()
|
||||
@@ -140,6 +168,23 @@ def get_computer(
|
||||
return computer
|
||||
|
||||
|
||||
@router.delete("/computers/{computer_id}")
|
||||
def delete_computer(
|
||||
computer_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
computer = db.query(Computer).filter(Computer.id == computer_id).first()
|
||||
if not computer:
|
||||
raise HTTPException(status_code=404, detail="Computer not found")
|
||||
|
||||
hostname = computer.hostname
|
||||
db.delete(computer)
|
||||
db.commit()
|
||||
log_action(db, admin, "computer_deleted", f"Deleted computer {hostname} (id={computer_id})")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/computers/export/csv")
|
||||
def export_csv(
|
||||
status: Optional[str] = None,
|
||||
@@ -169,7 +214,9 @@ def export_csv(
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"ID", "Hostname", "User", "IP", "Status", "OS", "CPU %", "RAM %",
|
||||
"RAM Total GB", "Uptime sec", "Last seen", "First seen",
|
||||
"RAM Used GB", "RAM Total GB", "RAM Available GB", "Swap %",
|
||||
"Swap Used GB", "Swap Total GB", "Swap Free GB", "Uptime sec",
|
||||
"Last seen", "First seen",
|
||||
])
|
||||
for c in computers:
|
||||
writer.writerow([
|
||||
@@ -179,10 +226,16 @@ def export_csv(
|
||||
c.current_ip or "",
|
||||
c.status,
|
||||
c.os_info or "",
|
||||
c.current_cpu_percent or "",
|
||||
c.current_ram_percent or "",
|
||||
c.current_ram_total_gb or "",
|
||||
c.current_uptime_sec or "",
|
||||
"" if c.current_cpu_percent is None else c.current_cpu_percent,
|
||||
"" if c.current_ram_percent is None else c.current_ram_percent,
|
||||
"" if c.current_ram_used_gb is None else c.current_ram_used_gb,
|
||||
"" if c.current_ram_total_gb is None else c.current_ram_total_gb,
|
||||
"" if c.current_ram_available_gb is None else c.current_ram_available_gb,
|
||||
"" if c.current_swap_percent is None else c.current_swap_percent,
|
||||
"" if c.current_swap_used_gb is None else c.current_swap_used_gb,
|
||||
"" if c.current_swap_total_gb is None else c.current_swap_total_gb,
|
||||
"" if c.current_swap_free_gb is None else c.current_swap_free_gb,
|
||||
"" if c.current_uptime_sec is None else c.current_uptime_sec,
|
||||
c.last_seen.isoformat() if c.last_seen else "",
|
||||
c.first_seen.isoformat() if c.first_seen else "",
|
||||
])
|
||||
@@ -230,3 +283,40 @@ def read_all_notifications(
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# Admin: audit log
|
||||
|
||||
@router.get("/audit", response_model=List[AuditLogOut])
|
||||
def list_audit(
|
||||
limit: int = 200,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
return db.query(AuditLog).order_by(desc(AuditLog.timestamp)).limit(limit).all()
|
||||
|
||||
|
||||
# Admin: settings
|
||||
|
||||
@router.get("/settings")
|
||||
def list_settings(
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
return get_all_settings(db)
|
||||
|
||||
|
||||
@router.post("/settings")
|
||||
def update_settings(
|
||||
payload: SettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
changed = []
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for key, value in data.items():
|
||||
set_setting(db, key, value)
|
||||
changed.append(key)
|
||||
if changed:
|
||||
log_action(db, admin, "settings_updated", f"Updated: {', '.join(changed)}")
|
||||
return get_all_settings(db)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
from database import get_db
|
||||
from auth import (
|
||||
@@ -12,10 +11,12 @@ from auth import (
|
||||
create_refresh_token,
|
||||
verify_token,
|
||||
get_current_user,
|
||||
require_admin,
|
||||
create_default_admin,
|
||||
log_action,
|
||||
)
|
||||
from models import User
|
||||
from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh
|
||||
from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh, UserUpdate, UserListOut
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -87,3 +88,87 @@ def me(user: User = Depends(get_current_user)):
|
||||
def logout(response: Response):
|
||||
response.delete_cookie("access_token")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# Admin user management
|
||||
|
||||
@router.get("/users", response_model=List[UserListOut])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
return db.query(User).order_by(User.username).all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
payload: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
existing = db.query(User).filter(User.username == payload.username).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
user = User(
|
||||
username=payload.username,
|
||||
password_hash=get_password_hash(payload.password),
|
||||
role="viewer",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_action(db, admin, "user_created", f"Created user {user.username} (id={user.id})")
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserOut)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user.id == admin.id and payload.role and payload.role != "admin":
|
||||
raise HTTPException(status_code=400, detail="Cannot downgrade yourself")
|
||||
|
||||
if payload.role:
|
||||
if payload.role not in ("admin", "viewer"):
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
user.role = payload.role
|
||||
|
||||
if payload.password:
|
||||
user.password_hash = get_password_hash(payload.password)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_action(db, admin, "user_updated", f"Updated user {user.username} (id={user.id}), role={user.role}, password_changed={bool(payload.password)}")
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
# prevent deleting last admin
|
||||
if user.role == "admin":
|
||||
admin_count = db.query(User).filter(User.role == "admin").count()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
|
||||
|
||||
username = user.username
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
log_action(db, admin, "user_deleted", f"Deleted user {username} (id={user_id})")
|
||||
return {"ok": True}
|
||||
|
||||
@@ -7,12 +7,17 @@ from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from database import get_db
|
||||
from auth import verify_token
|
||||
from models import Computer, Heartbeat, User, Notification
|
||||
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):
|
||||
@@ -25,6 +30,10 @@ def user_or_redirect(request: Request, db: Session):
|
||||
return db.query(User).filter(User.username == payload["sub"]).first()
|
||||
|
||||
|
||||
def has_admin(db: Session) -> bool:
|
||||
return db.query(User).filter(User.role == "admin").count() > 0
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def index(
|
||||
request: Request,
|
||||
@@ -85,8 +94,10 @@ def index(
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
return templates.TemplateResponse("login.html", {"request": request, "error": None})
|
||||
def login_page(request: Request, db: Session = Depends(get_db)):
|
||||
# Allow public registration only while no admin exists
|
||||
registration_open = not has_admin(db)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "error": None, "registration_open": registration_open})
|
||||
|
||||
|
||||
@router.get("/computers/{computer_id}", response_class=HTMLResponse)
|
||||
@@ -108,16 +119,30 @@ def computer_page(computer_id: int, request: Request, db: Session = Depends(get_
|
||||
)
|
||||
|
||||
hb_data = []
|
||||
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,
|
||||
"ram_percent": h.ram_percent,
|
||||
"ram_total_gb": h.ram_total_gb,
|
||||
"ram_used_gb": h.ram_used_gb,
|
||||
"ram_available_gb": h.ram_available_gb,
|
||||
"swap_total_gb": h.swap_total_gb,
|
||||
"swap_used_gb": h.swap_used_gb,
|
||||
"swap_free_gb": h.swap_free_gb,
|
||||
"swap_percent": h.swap_percent,
|
||||
"cpu_temp": h.cpu_temp,
|
||||
"processes": h.processes or [],
|
||||
"sessions": h.sessions or [],
|
||||
})
|
||||
# Take the most recent heartbeat that actually has session data
|
||||
for h in reversed(heartbeats):
|
||||
if h.sessions:
|
||||
latest_sessions = h.sessions
|
||||
break
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"computer.html",
|
||||
@@ -126,6 +151,7 @@ def computer_page(computer_id: int, request: Request, db: Session = Depends(get_
|
||||
"user": current_user,
|
||||
"computer": computer,
|
||||
"heartbeats": hb_data,
|
||||
"latest_sessions": latest_sessions,
|
||||
"unread_count": get_unread_count(db),
|
||||
},
|
||||
)
|
||||
@@ -153,3 +179,65 @@ def notifications_page(request: Request, db: Session = Depends(get_db)):
|
||||
"unread_count": get_unread_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Admin pages
|
||||
|
||||
@router.get("/admin/users", response_class=HTMLResponse)
|
||||
def admin_users_page(request: Request, db: Session = Depends(get_db)):
|
||||
current_user = user_or_redirect(request, db)
|
||||
if not current_user or current_user.role != "admin":
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
users = db.query(User).order_by(User.username).all()
|
||||
return templates.TemplateResponse(
|
||||
"admin_users.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
"users": users,
|
||||
"unread_count": get_unread_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/audit", response_class=HTMLResponse)
|
||||
def admin_audit_page(request: Request, db: Session = Depends(get_db)):
|
||||
current_user = user_or_redirect(request, db)
|
||||
if not current_user or current_user.role != "admin":
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
audit = (
|
||||
db.query(AuditLog)
|
||||
.order_by(desc(AuditLog.timestamp))
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"admin_audit.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
"audit": audit,
|
||||
"unread_count": get_unread_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/settings", response_class=HTMLResponse)
|
||||
def admin_settings_page(request: Request, db: Session = Depends(get_db)):
|
||||
current_user = user_or_redirect(request, db)
|
||||
if not current_user or current_user.role != "admin":
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
settings = get_all_settings(db)
|
||||
return templates.TemplateResponse(
|
||||
"admin_settings.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
"settings": settings,
|
||||
"timezones": get_common_timezones(),
|
||||
"unread_count": get_unread_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,6 +17,20 @@ class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
role: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class UserListOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
created_at: Optional[datetime]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
@@ -32,6 +46,13 @@ class LoginPayload(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class SessionInfo(BaseModel):
|
||||
username: Optional[str] = None
|
||||
tty: Optional[str] = None
|
||||
login_time: Optional[str] = None
|
||||
origin: Optional[str] = None
|
||||
|
||||
|
||||
class HeartbeatPayload(BaseModel):
|
||||
hostname: str
|
||||
username: str
|
||||
@@ -42,10 +63,17 @@ class HeartbeatPayload(BaseModel):
|
||||
cpu_percent: float
|
||||
ram_percent: float
|
||||
ram_total_gb: Optional[float] = None
|
||||
ram_used_gb: Optional[float] = None
|
||||
ram_available_gb: Optional[float] = None
|
||||
swap_total_gb: Optional[float] = None
|
||||
swap_used_gb: Optional[float] = None
|
||||
swap_free_gb: Optional[float] = None
|
||||
swap_percent: Optional[float] = None
|
||||
disk_info: Optional[List[dict]] = None
|
||||
cpu_temp: Optional[float] = None
|
||||
load_avg: Optional[str] = None
|
||||
processes: Optional[List[dict]] = None
|
||||
sessions: Optional[List[SessionInfo]] = None
|
||||
|
||||
|
||||
class HeartbeatOut(BaseModel):
|
||||
@@ -55,7 +83,15 @@ class HeartbeatOut(BaseModel):
|
||||
local_ip: Optional[str]
|
||||
cpu_percent: Optional[float]
|
||||
ram_percent: Optional[float]
|
||||
ram_total_gb: Optional[float]
|
||||
ram_used_gb: Optional[float]
|
||||
ram_available_gb: Optional[float]
|
||||
swap_total_gb: Optional[float]
|
||||
swap_used_gb: Optional[float]
|
||||
swap_free_gb: Optional[float]
|
||||
swap_percent: Optional[float]
|
||||
cpu_temp: Optional[float]
|
||||
sessions: Optional[List[SessionInfo]] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -71,6 +107,13 @@ class ComputerOut(BaseModel):
|
||||
current_ip: Optional[str]
|
||||
current_cpu_percent: Optional[float]
|
||||
current_ram_percent: Optional[float]
|
||||
current_ram_total_gb: Optional[float]
|
||||
current_ram_used_gb: Optional[float]
|
||||
current_ram_available_gb: Optional[float]
|
||||
current_swap_total_gb: Optional[float]
|
||||
current_swap_used_gb: Optional[float]
|
||||
current_swap_free_gb: Optional[float]
|
||||
current_swap_percent: Optional[float]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -90,3 +133,28 @@ class NotificationOut(BaseModel):
|
||||
read_at: Optional[datetime]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AuditLogOut(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int]
|
||||
username: Optional[str]
|
||||
action: str
|
||||
details: Optional[str]
|
||||
timestamp: Optional[datetime]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SettingOut(BaseModel):
|
||||
key: str
|
||||
value: Optional[str]
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
AGENT_TOKEN: Optional[str] = None
|
||||
OFFLINE_THRESHOLD_MINUTES: Optional[int] = None
|
||||
NOTIFICATION_WEBHOOK_URL: Optional[str] = None
|
||||
NOTIFICATION_TELEGRAM_BOT_TOKEN: Optional[str] = None
|
||||
NOTIFICATION_TELEGRAM_CHAT_ID: Optional[str] = None
|
||||
TIMEZONE: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from models import Setting
|
||||
from config import get_settings
|
||||
|
||||
_settings_cache = {}
|
||||
|
||||
|
||||
def get_setting(db: Session, key: str) -> Optional[str]:
|
||||
"""Get setting from DB, fallback to .env config."""
|
||||
s = db.query(Setting).filter(Setting.key == key).first()
|
||||
if s:
|
||||
return s.value
|
||||
|
||||
config = get_settings()
|
||||
return getattr(config, key, None)
|
||||
|
||||
|
||||
def set_setting(db: Session, key: str, value: Optional[str]) -> Setting:
|
||||
s = db.query(Setting).filter(Setting.key == key).first()
|
||||
if s:
|
||||
s.value = value
|
||||
else:
|
||||
s = Setting(key=key, value=value)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
_settings_cache[key] = value
|
||||
return s
|
||||
|
||||
|
||||
def get_all_settings(db: Session) -> dict:
|
||||
"""Return merged DB settings and .env defaults."""
|
||||
config = get_settings()
|
||||
result = {}
|
||||
keys = [
|
||||
"AGENT_TOKEN",
|
||||
"OFFLINE_THRESHOLD_MINUTES",
|
||||
"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()
|
||||
value = db_val.value if db_val else getattr(config, key, None)
|
||||
if key == "OFFLINE_THRESHOLD_MINUTES" and value is not None:
|
||||
try:
|
||||
value = int(value)
|
||||
except (TypeError, ValueError):
|
||||
value = getattr(config, key, None)
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (logout) {
|
||||
logout.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
window.location.href = '/login';
|
||||
});
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
@@ -6,6 +6,10 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-brand img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 0.75rem;
|
||||
border: 0;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Аудит-лог — InfoUser Monitor{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Аудит-лог</h2>
|
||||
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Действие</th>
|
||||
<th>Детали</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in audit %}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{ a.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>{{ a.username or '-' }}</td>
|
||||
<td><span class="badge bg-info">{{ a.action }}</span></td>
|
||||
<td class="text-muted small">{{ a.details or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Настройки — InfoUser Monitor{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Настройки</h2>
|
||||
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form id="settings-form">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">AGENT_TOKEN</label>
|
||||
<input type="text" class="form-control" id="AGENT_TOKEN" value="{{ settings.AGENT_TOKEN or '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">OFFLINE_THRESHOLD_MINUTES</label>
|
||||
<input type="number" class="form-control" id="OFFLINE_THRESHOLD_MINUTES" value="{{ settings.OFFLINE_THRESHOLD_MINUTES or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">NOTIFICATION_WEBHOOK_URL</label>
|
||||
<input type="text" class="form-control" id="NOTIFICATION_WEBHOOK_URL" value="{{ settings.NOTIFICATION_WEBHOOK_URL or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">NOTIFICATION_TELEGRAM_BOT_TOKEN</label>
|
||||
<input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_BOT_TOKEN" value="{{ settings.NOTIFICATION_TELEGRAM_BOT_TOKEN or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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 '' }}">
|
||||
</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>
|
||||
</form>
|
||||
<div id="settings-error" class="alert alert-danger mt-3 d-none"></div>
|
||||
<div id="settings-success" class="alert alert-success mt-3 d-none">Настройки сохранены</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('settings-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const err = document.getElementById('settings-error');
|
||||
const ok = document.getElementById('settings-success');
|
||||
err.classList.add('d-none');
|
||||
ok.classList.add('d-none');
|
||||
const body = {
|
||||
AGENT_TOKEN: document.getElementById('AGENT_TOKEN').value,
|
||||
OFFLINE_THRESHOLD_MINUTES: parseInt(document.getElementById('OFFLINE_THRESHOLD_MINUTES').value),
|
||||
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',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
ok.classList.remove('d-none');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
err.textContent = data.detail || 'Ошибка';
|
||||
err.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Управление пользователями — InfoUser Monitor{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Пользователи</h2>
|
||||
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Создать пользователя</h5>
|
||||
<form id="create-user-form" class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="new-username" placeholder="Логин" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="password" class="form-control" id="new-password" placeholder="Пароль" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="new-role">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="create-error" class="alert alert-danger mt-3 d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Логин</th>
|
||||
<th>Роль</th>
|
||||
<th>Создан</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr data-id="{{ u.id }}">
|
||||
<td>{{ u.id }}</td>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>
|
||||
<span class="badge {% if u.role == 'admin' %}bg-danger{% else %}bg-secondary{% endif %}">{{ u.role }}</span>
|
||||
</td>
|
||||
<td>{{ u.created_at | localtime }}</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-warning reset-password" data-id="{{ u.id }}" data-username="{{ u.username }}">Пароль</button>
|
||||
<button class="btn btn-sm btn-outline-danger delete-user" data-id="{{ u.id }}" data-username="{{ u.username }}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const err = document.getElementById('create-error');
|
||||
err.classList.add('d-none');
|
||||
const res = await fetch('/api/auth/users', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('new-username').value,
|
||||
password: document.getElementById('new-password').value,
|
||||
role: document.getElementById('new-role').value,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
err.textContent = data.detail || 'Ошибка';
|
||||
err.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.change-role').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const newRole = btn.dataset.role === 'admin' ? 'viewer' : 'admin';
|
||||
if (!confirm(`Сменить роль ${btn.dataset.username} на ${newRole}?`)) return;
|
||||
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({role: newRole}),
|
||||
});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Ошибка смены роли');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.reset-password').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const password = prompt(`Новый пароль для ${btn.dataset.username}:`);
|
||||
if (!password) return;
|
||||
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({password}),
|
||||
});
|
||||
if (res.ok) alert('Пароль изменён');
|
||||
else alert('Ошибка смены пароля');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.delete-user').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Удалить пользователя ${btn.dataset.username}?`)) return;
|
||||
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {method: 'DELETE', credentials: 'include'});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Ошибка удаления');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -7,15 +7,35 @@
|
||||
<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="/static/style.css">
|
||||
<script>
|
||||
window.SERVER_TIMEZONE = "{{ current_timezone() }}";
|
||||
</script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">InfoUser Monitor</a>
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="/static/logo.png" alt="InfoUser Monitor" height="36" onerror="this.outerHTML='InfoUser Monitor'">
|
||||
</a>
|
||||
{% if user %}
|
||||
<div class="collapse navbar-collapse">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
{% if user.role == 'admin' %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">{{ user.username }}</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="/admin/users">Пользователи</a></li>
|
||||
<li><a class="dropdown-item" href="/admin/settings">Настройки</a></li>
|
||||
<li><a class="dropdown-item" href="/admin/audit">Аудит-лог</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item"><span class="nav-link">{{ user.username }}</span></li>
|
||||
{% endif %}
|
||||
<li class="nav-item me-3">
|
||||
<a href="/notifications" class="nav-link position-relative">
|
||||
<i class="bi bi-bell"></i>
|
||||
@@ -26,7 +46,6 @@
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item"><span class="nav-link">{{ user.username }}</span></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/" id="logout-link">Выйти</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
<div class="card text-center shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Пользователь</div>
|
||||
{% if computer.current_user and ',' in computer.current_user %}
|
||||
<span class="badge bg-info text-dark" title="{{ computer.current_user }}">за ПК работают {{ computer.current_user.split(',') | length }} пользователя</span>
|
||||
{% else %}
|
||||
<div class="fw-bold">{{ computer.current_user or '-' }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,6 +51,70 @@
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card text-center shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">RAM</div>
|
||||
<div class="fw-bold">
|
||||
{% if computer.current_ram_used_gb is not none and computer.current_ram_total_gb is not none %}
|
||||
{{ computer.current_ram_used_gb | round(1) }} / {{ computer.current_ram_total_gb | round(1) }} GB
|
||||
<span class="text-muted small">({{ computer.current_ram_percent | round(1) if computer.current_ram_percent is not none else '-' }}%)</span>
|
||||
{% else %}
|
||||
{{ computer.current_ram_percent | round(1) if computer.current_ram_percent is not none else '-' }}%
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if computer.current_ram_available_gb is not none %}
|
||||
<div class="text-muted small">доступно {{ computer.current_ram_available_gb | round(1) }} GB</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card text-center shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Swap</div>
|
||||
<div class="fw-bold">
|
||||
{% if computer.current_swap_used_gb is not none and computer.current_swap_total_gb is not none %}
|
||||
{{ computer.current_swap_used_gb | round(1) }} / {{ computer.current_swap_total_gb | round(1) }} GB
|
||||
<span class="text-muted small">({{ computer.current_swap_percent | round(1) if computer.current_swap_percent is not none else '-' }}%)</span>
|
||||
{% else %}
|
||||
{{ computer.current_swap_percent | round(1) if computer.current_swap_percent is not none else '-' }}%
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if computer.current_swap_free_gb is not none %}
|
||||
<div class="text-muted small">свободно {{ computer.current_swap_free_gb | round(1) }} GB</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Активные сессии</h5>
|
||||
{% if latest_sessions %}
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr><th>Пользователь</th><th>TTY</th><th>Время входа</th><th>Откуда</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in latest_sessions %}
|
||||
<tr>
|
||||
<td>{{ s.username or '-' }}</td>
|
||||
<td>{{ s.tty or '-' }}</td>
|
||||
<td>{{ s.login_time or '-' }}</td>
|
||||
<td>{{ s.origin or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">Нет данных о сессиях.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">CPU, %</h5>
|
||||
@@ -54,7 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">RAM, %</h5>
|
||||
@@ -62,6 +130,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Swap, %</h5>
|
||||
<canvas id="swapChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
@@ -91,13 +167,21 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">История heartbeats</h5>
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Время</th><th>CPU</th><th>RAM</th><th>IP</th></tr></thead>
|
||||
<thead><tr><th>Время</th><th>CPU</th><th>RAM</th><th>RAM GB</th><th>Swap</th><th>IP</th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in heartbeats[-20:] | reverse %}
|
||||
<tr>
|
||||
<td>{{ h.timestamp[:16] | replace('T', ' ') if h.timestamp else '-' }}</td>
|
||||
<td>{{ h.cpu_percent | round(1) }}%</td>
|
||||
<td>{{ h.ram_percent | round(1) }}%</td>
|
||||
<td>
|
||||
{% if h.ram_used_gb is not none and h.ram_total_gb is not none %}
|
||||
{{ h.ram_used_gb | round(1) }} / {{ h.ram_total_gb | round(1) }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ h.swap_percent | round(1) if h.swap_percent is not none else '-' }}%</td>
|
||||
<td>{{ h.local_ip or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -106,22 +190,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const heartbeats = {{ heartbeats | tojson }};
|
||||
const labels = heartbeats.map(h => h.timestamp.replace('T', ' ').slice(0, 16));
|
||||
const cpuData = heartbeats.map(h => h.cpu_percent);
|
||||
const ramData = heartbeats.map(h => h.ram_percent);
|
||||
const cpuData = heartbeats.map(h => h.cpu_percent ?? null);
|
||||
const ramData = heartbeats.map(h => h.ram_percent ?? null);
|
||||
const swapData = heartbeats.map(h => h.swap_percent ?? null);
|
||||
|
||||
new Chart(document.getElementById('cpuChart'), {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: 'CPU %', data: cpuData, borderColor: 'rgb(255, 99, 132)', tension: 0.2 }] },
|
||||
options: { scales: { y: { beginAtZero: true, max: 100 } } }
|
||||
options: { scales: { y: { beginAtZero: true, max: 100 } }, spanGaps: true }
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('ramChart'), {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: 'RAM %', data: ramData, borderColor: 'rgb(54, 162, 235)', tension: 0.2 }] },
|
||||
options: { scales: { y: { beginAtZero: true, max: 100 } } }
|
||||
options: { scales: { y: { beginAtZero: true, max: 100 } }, spanGaps: true }
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('swapChart'), {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: 'Swap %', data: swapData, borderColor: 'rgb(255, 159, 64)', tension: 0.2 }] },
|
||||
options: { scales: { y: { beginAtZero: true, max: 100 } }, spanGaps: true }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+103
-12
@@ -6,9 +6,9 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Компьютеры</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/api/computers/export/csv?{{ request.query_params }}" class="btn btn-outline-success btn-sm" target="_blank">
|
||||
<button type="button" class="btn btn-outline-success btn-sm" id="export-csv-btn">
|
||||
<i class="bi bi-download"></i> Экспорт CSV
|
||||
</a>
|
||||
</button>
|
||||
<a href="/notifications" class="btn btn-outline-info btn-sm">
|
||||
<i class="bi bi-bell"></i> Уведомления {% if unread_count %}<span class="badge bg-danger">{{ unread_count }}</span>{% endif %}
|
||||
</a>
|
||||
@@ -59,16 +59,23 @@
|
||||
<th>Статус</th>
|
||||
<th>CPU</th>
|
||||
<th>RAM</th>
|
||||
<th>Swap</th>
|
||||
<th>OS</th>
|
||||
<th>Последний чек</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody>
|
||||
{% for c in computers %}
|
||||
<tr data-id="{{ c.id }}">
|
||||
<td><strong>{{ c.hostname }}</strong></td>
|
||||
<td>{{ c.current_user or '-' }}</td>
|
||||
<td>
|
||||
{% if c.current_user and ',' in c.current_user %}
|
||||
<span class="badge bg-info text-dark" title="{{ c.current_user }}">за ПК работают {{ c.current_user.split(',') | length }} пользователя</span>
|
||||
{% else %}
|
||||
{{ c.current_user or '-' }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ c.current_ip or '-' }}</td>
|
||||
<td>
|
||||
{% if c.status == 'online' %}
|
||||
@@ -78,10 +85,22 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
{% if c.current_ram_used_gb is not none and c.current_ram_total_gb is not none %}
|
||||
{{ c.current_ram_used_gb | round(1) }} / {{ c.current_ram_total_gb | round(1) }} GB
|
||||
{% else %}
|
||||
{{ c.current_ram_percent | round(1) if c.current_ram_percent is not none else '-' }}%
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ c.current_swap_percent | round(1) if c.current_swap_percent is not none else '-' }}%</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><a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a></td>
|
||||
<td class="last-seen">{{ c.last_seen | localtime }}</td>
|
||||
<td>
|
||||
<a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a>
|
||||
{% if user.role == 'admin' %}
|
||||
<button class="btn btn-sm btn-outline-danger delete-pc" data-id="{{ c.id }}" data-hostname="{{ c.hostname }}">Удалить</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -94,29 +113,101 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const isAdmin = {{ 'true' if user.role == 'admin' else 'false' }};
|
||||
|
||||
function formatCurrentUser(value) {
|
||||
if (!value) return '-';
|
||||
if (value.includes(',')) {
|
||||
const count = value.split(',').length;
|
||||
return `<span class="badge bg-info text-dark" title="${escapeHtml(value)}">за ПК работают ${count} пользователя</span>`;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
async function deleteComputer(id, hostname) {
|
||||
if (!confirm(`Удалить ПК ${hostname}?`)) return;
|
||||
const res = await fetch(`/api/computers/${id}`, {method: 'DELETE', credentials: 'include'});
|
||||
if (res.ok) location.reload();
|
||||
else if (res.status === 401) location.href = '/login';
|
||||
else alert('Ошибка удаления');
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const res = await fetch('/api/computers/export/csv?' + currentQuery(), {credentials: 'include'});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) location.href = '/login';
|
||||
else alert('Ошибка экспорта');
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const disposition = res.headers.get('content-disposition');
|
||||
let filename = 'computers.csv';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
document.getElementById('export-csv-btn').addEventListener('click', exportCsv);
|
||||
|
||||
document.querySelectorAll('.delete-pc').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteComputer(btn.dataset.id, btn.dataset.hostname));
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await fetch('/api/computers?' + currentQuery());
|
||||
const res = await fetch('/api/computers?' + currentQuery(), {credentials: 'include'});
|
||||
if (!res.ok) return location.reload();
|
||||
const data = await res.json();
|
||||
const tbody = document.querySelector('#computers-table tbody');
|
||||
tbody.innerHTML = data.map(c => `
|
||||
<tr data-id="${c.id}">
|
||||
<td><strong>${escapeHtml(c.hostname)}</strong></td>
|
||||
<td>${escapeHtml(c.current_user || '-')}</td>
|
||||
<td>${formatCurrentUser(c.current_user)}</td>
|
||||
<td>${escapeHtml(c.current_ip || '-')}</td>
|
||||
<td>${c.status === 'online' ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>'}</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_used_gb != null && c.current_ram_total_gb != null ? `${c.current_ram_used_gb.toFixed(1)} / ${c.current_ram_total_gb.toFixed(1)} GB` : (c.current_ram_percent != null ? c.current_ram_percent.toFixed(1) + '%' : '-')}</td>
|
||||
<td>${c.current_swap_percent != null ? c.current_swap_percent.toFixed(1) : '-'}%</td>
|
||||
<td>${escapeHtml(c.os_info || '-')}</td>
|
||||
<td class="last-seen">${c.last_seen ? c.last_seen.replace('T', ' ').slice(0, 16) : '-'}</td>
|
||||
<td><a href="/computers/${c.id}" class="btn btn-sm btn-outline-primary">Детали</a></td>
|
||||
<td class="last-seen">${formatServerTime(c.last_seen)}</td>
|
||||
<td>
|
||||
<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>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
document.querySelectorAll('.delete-pc').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteComputer(btn.dataset.id, btn.dataset.hostname));
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Dashboard refresh failed', e);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,21 @@
|
||||
<div class="alert alert-danger mt-3">{{ error }}</div>
|
||||
{% endif %}
|
||||
<div id="login-error" class="alert alert-danger mt-3 d-none"></div>
|
||||
|
||||
{% if registration_open %}
|
||||
<hr>
|
||||
<h6 class="text-center">Или создайте первого администратора</h6>
|
||||
<form id="register-form">
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" id="reg-username" placeholder="Логин" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="password" class="form-control" id="reg-password" placeholder="Пароль" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-secondary w-100">Зарегистрироваться</button>
|
||||
</form>
|
||||
<div id="register-error" class="alert alert-danger mt-3 d-none"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-3 text-muted small">
|
||||
@@ -42,7 +57,7 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
form.append('username', document.getElementById('username').value);
|
||||
form.append('password', document.getElementById('password').value);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', { method: 'POST', body: form });
|
||||
const res = await fetch('/api/auth/login', { method: 'POST', body: form, credentials: 'include' });
|
||||
if (res.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
@@ -55,5 +70,29 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
err.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
{% if registration_open %}
|
||||
document.getElementById('register-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const err = document.getElementById('register-error');
|
||||
err.classList.add('d-none');
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('reg-username').value,
|
||||
password: document.getElementById('reg-password').value,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const data = await res.json();
|
||||
err.textContent = data.detail || 'Ошибка регистрации';
|
||||
err.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div>
|
||||
<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"><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>
|
||||
{% if not n.is_read %}
|
||||
<button class="btn btn-sm btn-outline-success mark-read" data-id="{{ n.id }}">Прочитать</button>
|
||||
@@ -36,7 +36,7 @@
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function markRead(id) {
|
||||
const res = await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
|
||||
const res = await fetch(`/api/notifications/${id}/read`, { method: 'POST', credentials: 'include' });
|
||||
if (res.ok) location.reload();
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ document.querySelectorAll('.mark-read').forEach(btn => {
|
||||
});
|
||||
|
||||
document.getElementById('mark-all-read')?.addEventListener('click', async () => {
|
||||
const res = await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||
const res = await fetch('/api/notifications/read-all', { method: 'POST', credentials: 'include' });
|
||||
if (res.ok) location.reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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