13 Commits
Author SHA1 Message Date
Hidosi f9018f83b9 Update README with mass deploy instructions and deploy/ structure 2026-07-20 18:02:00 +07:00
Hidosi e1372b0584 Remove hardcoded SSH password; prompt interactively or use env var 2026-07-20 17:49:03 +07:00
Hidosi 5e77c162c7 Add mass agent deployment script with SSH key/password auth and apt fallback 2026-07-20 17:42:11 +07:00
Hidosi 49a884c01d Replace navbar text with logo image and add fallback text 2026-07-20 16:14:50 +07:00
Hidosi 3b5ac3d282 Update README with current features: sessions, timezone, RAM/swap, RBAC, audit 2026-07-17 17:20:11 +07:00
Hidosi 1560592af4 Fix CSV export to preserve zero values for RAM/swap metrics 2026-07-17 17:11:41 +07:00
Hidosi 76f74201e5 Add RAM usage in GB and swap metrics to agent, server, UI and CSV export 2026-07-17 17:10:47 +07:00
Hidosi 513b8ede84 Add configurable TIMEZONE for web UI display (UTC storage preserved) 2026-07-17 12:02:58 +07:00
Hidosi 559025d5f7 Build agent .deb 1.0.1 with session tracking 2026-07-17 09:51:26 +07:00
Hidosi 7b2e5172ea Add active user session tracking from agent user :0 2026-07-16 15:46
user     pts/0        2026-07-16 15:50 (:0)
user     pts/1        2026-07-16 16:46 (:0)
user     pts/2        2026-07-17 09:05 (:0) to server UI
2026-07-17 09:44:39 +07:00
Hidosi 8e62eaf3f3 Fix JS fetch credentials and CSV export from web UI 2026-07-16 18:32:37 +07:00
Hidosi 677f6028d9 Fix cookie auth for API endpoints and clean up navbar 2026-07-16 18:16:27 +07:00
Hidosi d1128f4aa3 Add RBAC: admin user management, audit log, runtime settings, delete PC 2026-07-16 17:28:04 +07:00
32 changed files with 1924 additions and 89 deletions
+66 -11
View File
@@ -1,33 +1,46 @@
# InfoUser Monitor # InfoUser Monitor
Мониторинг состояния ПК (Windows/Linux/macOS). Агент на каждом компьютере отправляет метрики на сервер раз в час, а веб-интерфейс показывает список машин, пользователей, IP, CPU/RAM, диски и процессы. Мониторинг состояния ПК (Windows/Linux/macOS). Агент на каждом компьютере отправляет метрики на сервер раз в час, а веб-интерфейс показывает список машин, пользователей, IP, CPU/RAM/swap, диски, процессы и активные пользовательские сессии.
## Возможности ## Возможности
- **Дашборд** с фильтрами по статусу, пользователю и поиском по хосту/IP/OS - **Дашборд** с фильтрами по статусу, пользователю и поиском по хосту/IP/OS
- **Графики** CPU/RAM за 24 часа на странице ПК - **Детали ПК**: графики CPU/RAM/swap за 24 часа, активные сессии, топ процессов, история heartbeats
- **Активные сессии**: кто и с какого IP залогинен прямо сейчас
- **Уведомления** об офлайн-ПК (in-app + webhook + Telegram) - **Уведомления** об офлайн-ПК (in-app + webhook + Telegram)
- **Экспорт** списка ПК в CSV - **Экспорт** списка ПК в CSV
- **JWT-авторизация** с ролями admin/viewer - **JWT-авторизация** с ролями admin/viewer
- **RBAC и аудит-лог**: управление пользователями, запись действий админов
- **Настройки через веб**: токен агента, порог офлайна, уведомления, часовой пояс
- **Часовой пояс**: хранение в UTC, отображение в выбранном поясе
- **RAM и swap**: использование в GB и процентах
## Структура ## Структура
``` ```
info-user/ info-user/
├── server/ # FastAPI + веб-интерфейс ├── server/ # FastAPI + веб-интерфейс
│ ├── routes/ │ ├── routes/ # API и веб-роуты
│ ├── templates/ │ ├── templates/ # Jinja2-шаблоны
│ ├── static/ │ ├── static/ # CSS/JS
│ ├── database.py # SQLite и миграции
│ ├── models.py # SQLAlchemy-модели
│ ├── schemas.py # Pydantic-схемы
│ ├── Dockerfile │ ├── Dockerfile
│ └── main.py │ └── main.py
├── agent/ # Python-агент ├── agent/ # Python-агент (ручная установка)
│ ├── agent.py │ ├── agent.py
│ ├── config.json │ ├── config.json
│ └── install.sh │ └── install.sh
├── agent-deb/ # Исходники .deb пакета (dpkg-deb --build) ├── agent-deb/ # Исходники .deb пакета (dpkg-deb --build)
├── deploy/ # Массовый деплой агента по SSH
│ ├── deploy-agent.sh
│ ├── ip.txt.example
│ └── README.md
├── data/ # SQLite БД (создаётся при запуске) ├── data/ # SQLite БД (создаётся при запуске)
├── docker-compose.yml ├── docker-compose.yml
├── nginx.conf ├── nginx.conf
├── info-user-agent_*.deb
└── README.md └── README.md
``` ```
@@ -75,7 +88,7 @@ http://localhost
```bash ```bash
# Установить .deb пакет # Установить .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 sudo info-user-agent-setup
@@ -84,7 +97,7 @@ sudo info-user-agent-setup
sudo nano /opt/info-user-agent/config.json sudo nano /opt/info-user-agent/config.json
``` ```
Либо через репозиторий (добавив его в `/etc/apt/sources.list.d/`). Либо через собственный apt-репозиторий, если он настроен на сервере.
### Вручную (любой Linux) ### Вручную (любой Linux)
@@ -95,6 +108,18 @@ sudo bash install.sh
Не забудьте отредактировать `agent/config.json`, указав `server_url` и `agent_token`. Не забудьте отредактировать `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`: Файл `agent/config.json`:
@@ -113,10 +138,13 @@ sudo bash install.sh
- Имя хоста, текущий пользователь, локальный IP - Имя хоста, текущий пользователь, локальный IP
- Операционная система - Операционная система
- Uptime - Uptime
- Загрузка CPU и RAM - Загрузка CPU
- RAM: процент, использовано/доступно/всего в GB
- Swap: процент, использовано/свободно/всего в GB
- Информация по дискам - Информация по дискам
- Температура CPU (если доступна) - Температура CPU (если доступна)
- Top-10 процессов по CPU - Top-10 процессов по CPU/RAM
- Активные пользовательские сессии (`who -u`)
## API ## API
@@ -124,11 +152,31 @@ sudo bash install.sh
- `GET /api/computers` — список компьютеров (с фильтрами `status`, `user`, `search`, `sort`, `order`) - `GET /api/computers` — список компьютеров (с фильтрами `status`, `user`, `search`, `sort`, `order`)
- `GET /api/computers/export/csv` — экспорт в CSV - `GET /api/computers/export/csv` — экспорт в CSV
- `GET /api/computers/{id}` — детали ПК с историей - `GET /api/computers/{id}` — детали ПК с историей
- `DELETE /api/computers/{id}` — удалить ПК (admin)
- `GET /api/notifications` — уведомления - `GET /api/notifications` — уведомления
- `POST /api/notifications/{id}/read` — отметить прочитанным - `POST /api/notifications/{id}/read` — отметить прочитанным
- `POST /api/notifications/read-all` — отметить все прочитанными - `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/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] Уведомления об офлайн-ПК (in-app + webhook + Telegram)
- [X] Экспорт в CSV - [X] Экспорт в CSV
- [X] JWT-авторизация - [X] JWT-авторизация
- [X] Ролевая модель admin/viewer
- [X] Аудит-лог
- [X] Runtime-настройки через веб
- [X] Активные пользовательские сессии
- [X] RAM и swap в GB/%
- [X] График swap
- [X] Часовой пояс в веб-интерфейсе
- [ ] Email-уведомления (SMTP) - [ ] Email-уведомления (SMTP)
- [ ] Интеграция с Telegram-ботом (двусторонняя) - [ ] Интеграция с Telegram-ботом (двусторонняя)
- [ ] Ролевая модель для уведомлений (какие роли получают какие типы уведомлений) - [ ] Ролевая модель для уведомлений (какие роли получают какие типы уведомлений)
+4 -3
View File
@@ -1,13 +1,14 @@
Package: info-user-agent Package: info-user-agent
Version: 1.0.0 Version: 1.0.3
Section: admin Section: admin
Priority: optional Priority: optional
Architecture: all Architecture: all
Depends: python3, python3-psutil, python3-requests Depends: python3, python3-psutil, python3-requests
Maintainer: Hidosi <hidosi@hidosi.ru> Maintainer: Hidosi <hidosi@hidosi.ru>
Description: InfoUser Monitor - агент мониторинга ПК Description: InfoUser Monitor - агент мониторинга ПК
Агент собирает метрики компьютера (CPU, RAM, диски, процессы, пользователь, IP) Агент собирает метрики компьютера (CPU, RAM, диски, процессы, активные
и отправляет их раз в час на сервер InfoUser Monitor. пользовательские сессии, IP) и отправляет их раз в час на сервер
InfoUser Monitor.
. .
После установки запустите: После установки запустите:
sudo info-user-agent-setup sudo info-user-agent-setup
+106 -10
View File
@@ -7,6 +7,7 @@ import getpass
import json import json
import requests import requests
import logging import logging
import subprocess
from pathlib import Path from pathlib import Path
logging.basicConfig( logging.basicConfig(
@@ -70,6 +71,62 @@ def get_disk_info():
return disks 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(): def get_cpu_temp():
try: try:
temps = psutil.sensors_temperatures() temps = psutil.sensors_temperatures()
@@ -90,15 +147,31 @@ def get_load_avg():
return None 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 = [] procs = []
for p in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]): for p in processes:
try: try:
procs.append({ procs.append({
"pid": p.info["pid"], "pid": p.pid,
"name": p.info["name"], "name": p.name(),
"cpu_percent": round(p.info["cpu_percent"] or 0, 1), "cpu_percent": round(p.cpu_percent(interval=None), 1),
"ram_percent": round((p.info["memory_percent"] or 0), 1), "ram_percent": round(p.memory_percent(), 1),
}) })
except Exception: except Exception:
continue continue
@@ -108,20 +181,43 @@ def get_top_processes(limit=10):
def collect(): def collect():
cfg = load_config() 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 = { payload = {
"hostname": socket.gethostname(), "hostname": socket.gethostname(),
"username": getpass.getuser(), "username": get_display_username(sessions),
"sessions": sessions,
"os_info": f"{platform.system()} {platform.release()}", "os_info": f"{platform.system()} {platform.release()}",
"local_ip": get_local_ip(), "local_ip": get_local_ip(),
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None, "public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
"uptime_sec": get_uptime(), "uptime_sec": get_uptime(),
"cpu_percent": psutil.cpu_percent(interval=1), "cpu_percent": psutil.cpu_percent(interval=1),
"ram_percent": psutil.virtual_memory().percent, "ram_percent": vm.percent,
"ram_total_gb": round(psutil.virtual_memory().total / (2**30), 1), "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(), "disk_info": get_disk_info(),
"cpu_temp": get_cpu_temp(), "cpu_temp": get_cpu_temp(),
"load_avg": get_load_avg(), "load_avg": get_load_avg(),
"processes": get_top_processes(10), "processes": get_top_processes(10, baseline_procs),
} }
return payload, cfg return payload, cfg
+106 -10
View File
@@ -7,6 +7,7 @@ import getpass
import json import json
import requests import requests
import logging import logging
import subprocess
from pathlib import Path from pathlib import Path
logging.basicConfig( logging.basicConfig(
@@ -70,6 +71,62 @@ def get_disk_info():
return disks 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(): def get_cpu_temp():
try: try:
temps = psutil.sensors_temperatures() temps = psutil.sensors_temperatures()
@@ -90,15 +147,31 @@ def get_load_avg():
return None 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 = [] procs = []
for p in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]): for p in processes:
try: try:
procs.append({ procs.append({
"pid": p.info["pid"], "pid": p.pid,
"name": p.info["name"], "name": p.name(),
"cpu_percent": round(p.info["cpu_percent"] or 0, 1), "cpu_percent": round(p.cpu_percent(interval=None), 1),
"ram_percent": round((p.info["memory_percent"] or 0), 1), "ram_percent": round(p.memory_percent(), 1),
}) })
except Exception: except Exception:
continue continue
@@ -108,20 +181,43 @@ def get_top_processes(limit=10):
def collect(): def collect():
cfg = load_config() 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 = { payload = {
"hostname": socket.gethostname(), "hostname": socket.gethostname(),
"username": getpass.getuser(), "username": get_display_username(sessions),
"sessions": sessions,
"os_info": f"{platform.system()} {platform.release()}", "os_info": f"{platform.system()} {platform.release()}",
"local_ip": get_local_ip(), "local_ip": get_local_ip(),
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None, "public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
"uptime_sec": get_uptime(), "uptime_sec": get_uptime(),
"cpu_percent": psutil.cpu_percent(interval=1), "cpu_percent": psutil.cpu_percent(interval=1),
"ram_percent": psutil.virtual_memory().percent, "ram_percent": vm.percent,
"ram_total_gb": round(psutil.virtual_memory().total / (2**30), 1), "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(), "disk_info": get_disk_info(),
"cpu_temp": get_cpu_temp(), "cpu_temp": get_cpu_temp(),
"load_avg": get_load_avg(), "load_avg": get_load_avg(),
"processes": get_top_processes(10), "processes": get_top_processes(10, baseline_procs),
} }
return payload, cfg return payload, cfg
+118
View File
@@ -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
```
+395
View File
@@ -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 "$@"
+7
View File
@@ -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
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from config import get_settings from config import get_settings
@@ -50,9 +50,12 @@ def verify_token(token: str, token_type: str) -> Optional[dict]:
def get_current_user( def get_current_user(
request: Request,
token: Optional[str] = Depends(oauth2_scheme), token: Optional[str] = Depends(oauth2_scheme),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> Optional[models.User]: ) -> Optional[models.User]:
if not token:
token = request.cookies.get("access_token")
if not token: if not token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -74,6 +77,20 @@ def require_admin(user: models.User = Depends(get_current_user)):
return 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]: def create_default_admin(db: Session) -> Optional[models.User]:
if not settings.CREATE_ADMIN_ON_STARTUP: if not settings.CREATE_ADMIN_ON_STARTUP:
return None return None
+1
View File
@@ -17,6 +17,7 @@ class Settings(BaseSettings):
NOTIFICATION_WEBHOOK_URL: str = "" NOTIFICATION_WEBHOOK_URL: str = ""
NOTIFICATION_TELEGRAM_BOT_TOKEN: str = "" NOTIFICATION_TELEGRAM_BOT_TOKEN: str = ""
NOTIFICATION_TELEGRAM_CHAT_ID: str = "" NOTIFICATION_TELEGRAM_CHAT_ID: str = ""
TIMEZONE: str = "Asia/Krasnoyarsk"
class Config: class Config:
env_file = ".env" env_file = ".env"
+49
View File
@@ -24,3 +24,52 @@ def get_db():
def create_tables(): def create_tables():
Base.metadata.create_all(bind=engine) 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
View File
@@ -1,16 +1,19 @@
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from database import create_tables, SessionLocal from database import create_tables, run_migrations, SessionLocal
from auth import create_default_admin from auth import create_default_admin
from routes import auth_router, api_router, web_router from routes import auth_router, api_router, web_router
from settings_store import get_setting
from timezone_utils import set_current_timezone
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
create_tables() create_tables()
run_migrations()
db = SessionLocal() db = SessionLocal()
try: try:
create_default_admin(db) create_default_admin(db)
@@ -36,6 +39,22 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
@app.middleware("http")
async def timezone_middleware(request: Request, call_next):
"""Set current request timezone from DB settings for web rendering."""
db = SessionLocal()
try:
tz = get_setting(db, "TIMEZONE")
if tz:
set_current_timezone(tz)
else:
set_current_timezone("UTC")
finally:
db.close()
return await call_next(request)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(web_router) app.include_router(web_router)
+23
View File
@@ -28,6 +28,12 @@ class Computer(Base):
current_cpu_percent = Column(Float, nullable=True) current_cpu_percent = Column(Float, nullable=True)
current_ram_percent = Column(Float, nullable=True) current_ram_percent = Column(Float, nullable=True)
current_ram_total_gb = 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): class Heartbeat(Base):
@@ -43,10 +49,17 @@ class Heartbeat(Base):
cpu_percent = Column(Float, nullable=True) cpu_percent = Column(Float, nullable=True)
ram_percent = Column(Float, nullable=True) ram_percent = Column(Float, nullable=True)
ram_total_gb = 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) disk_info = Column(JSON, nullable=True)
cpu_temp = Column(Float, nullable=True) cpu_temp = Column(Float, nullable=True)
load_avg = Column(String, nullable=True) load_avg = Column(String, nullable=True)
processes = Column(JSON, nullable=True) processes = Column(JSON, nullable=True)
sessions = Column(JSON, nullable=True)
class AuditLog(Base): class AuditLog(Base):
@@ -54,6 +67,7 @@ class AuditLog(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
username = Column(String, nullable=True)
action = Column(String, nullable=False) action = Column(String, nullable=False)
details = Column(Text, nullable=True) details = Column(Text, nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now()) 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()) sent_at = Column(DateTime(timezone=True), server_default=func.now())
read_at = Column(DateTime(timezone=True), nullable=True) 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
View File
@@ -6,11 +6,19 @@ from sqlalchemy.orm import Session
from config import get_settings from config import get_settings
from models import Computer, Notification from models import Computer, Notification
from settings_store import get_setting
settings = get_settings() settings = get_settings()
logger = logging.getLogger(__name__) 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: def create_notification(db: Session, computer: Computer, ntype: str, title: str, message: str) -> Notification:
n = Notification( n = Notification(
computer_id=computer.id, computer_id=computer.id,
@@ -21,13 +29,17 @@ def create_notification(db: Session, computer: Computer, ntype: str, title: str,
db.add(n) db.add(n)
db.commit() db.commit()
db.refresh(n) db.refresh(n)
send_external_notifications(n, computer) send_external_notifications(db, n, computer)
return n return n
def check_offline_computers(db: Session): def check_offline_computers(db: Session):
"""Mark online computers that missed the threshold as offline and create notifications.""" """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 = ( stale = (
db.query(Computer) db.query(Computer)
.filter(Computer.status == "online", Computer.last_seen < threshold) .filter(Computer.status == "online", Computer.last_seen < threshold)
@@ -49,7 +61,7 @@ def check_offline_computers(db: Session):
if not existing: if not existing:
msg = ( msg = (
f"ПК {computer.hostname} не присылал данные более " f"ПК {computer.hostname} не присылал данные более "
f"{settings.OFFLINE_THRESHOLD_MINUTES} минут. " f"{threshold_minutes} минут. "
f"Последний пользователь: {computer.current_user or '-'}, " f"Последний пользователь: {computer.current_user or '-'}, "
f"IP: {computer.current_ip or '-'}" f"IP: {computer.current_ip or '-'}"
) )
@@ -57,11 +69,15 @@ def check_offline_computers(db: Session):
db.commit() db.commit()
def send_external_notifications(notification: Notification, computer: Computer): def send_external_notifications(db: Session, notification: Notification, computer: Computer):
if settings.NOTIFICATION_WEBHOOK_URL: 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: try:
requests.post( requests.post(
settings.NOTIFICATION_WEBHOOK_URL, webhook_url,
json={ json={
"type": notification.type, "type": notification.type,
"title": notification.title, "title": notification.title,
@@ -75,13 +91,13 @@ def send_external_notifications(notification: Notification, computer: Computer):
except Exception as e: except Exception as e:
logger.error("Webhook notification failed: %s", 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: try:
text = f"*{notification.title}*\n\n{notification.message}" text = f"*{notification.title}*\n\n{notification.message}"
requests.post( requests.post(
f"https://api.telegram.org/bot{settings.NOTIFICATION_TELEGRAM_BOT_TOKEN}/sendMessage", f"https://api.telegram.org/bot{telegram_token}/sendMessage",
json={ json={
"chat_id": settings.NOTIFICATION_TELEGRAM_CHAT_ID, "chat_id": telegram_chat,
"text": text, "text": text,
"parse_mode": "Markdown", "parse_mode": "Markdown",
}, },
+99 -9
View File
@@ -10,10 +10,20 @@ from typing import List, Optional
from config import get_settings from config import get_settings
from database import get_db from database import get_db
from models import Computer, Heartbeat, Notification from models import Computer, Heartbeat, Notification, AuditLog
from schemas import HeartbeatPayload, ComputerOut, ComputerDetailOut, HeartbeatOut, NotificationOut from schemas import (
from auth import get_current_user 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 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"]) router = APIRouter(prefix="/api", tags=["api"])
settings = get_settings() settings = get_settings()
@@ -33,7 +43,8 @@ def heartbeat(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
token = get_agent_token(request) 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") raise HTTPException(status_code=401, detail="Invalid agent token")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -57,6 +68,16 @@ def heartbeat(
computer.current_cpu_percent = payload.cpu_percent computer.current_cpu_percent = payload.cpu_percent
computer.current_ram_percent = payload.ram_percent computer.current_ram_percent = payload.ram_percent
computer.current_ram_total_gb = payload.ram_total_gb 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( heartbeat = Heartbeat(
computer_id=computer.id, computer_id=computer.id,
@@ -68,10 +89,17 @@ def heartbeat(
cpu_percent=payload.cpu_percent, cpu_percent=payload.cpu_percent,
ram_percent=payload.ram_percent, ram_percent=payload.ram_percent,
ram_total_gb=payload.ram_total_gb, 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 [], disk_info=payload.disk_info or [],
cpu_temp=payload.cpu_temp, cpu_temp=payload.cpu_temp,
load_avg=payload.load_avg, load_avg=payload.load_avg,
processes=payload.processes or [], processes=payload.processes or [],
sessions=sessions,
) )
db.add(heartbeat) db.add(heartbeat)
db.commit() db.commit()
@@ -140,6 +168,23 @@ def get_computer(
return 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") @router.get("/computers/export/csv")
def export_csv( def export_csv(
status: Optional[str] = None, status: Optional[str] = None,
@@ -169,7 +214,9 @@ def export_csv(
writer = csv.writer(output) writer = csv.writer(output)
writer.writerow([ writer.writerow([
"ID", "Hostname", "User", "IP", "Status", "OS", "CPU %", "RAM %", "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: for c in computers:
writer.writerow([ writer.writerow([
@@ -179,10 +226,16 @@ def export_csv(
c.current_ip or "", c.current_ip or "",
c.status, c.status,
c.os_info or "", c.os_info or "",
c.current_cpu_percent or "", "" if c.current_cpu_percent is None else c.current_cpu_percent,
c.current_ram_percent or "", "" if c.current_ram_percent is None else c.current_ram_percent,
c.current_ram_total_gb or "", "" if c.current_ram_used_gb is None else c.current_ram_used_gb,
c.current_uptime_sec or "", "" 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.last_seen.isoformat() if c.last_seen else "",
c.first_seen.isoformat() if c.first_seen else "", c.first_seen.isoformat() if c.first_seen else "",
]) ])
@@ -230,3 +283,40 @@ def read_all_notifications(
) )
db.commit() db.commit()
return {"ok": True} 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)
+88 -3
View File
@@ -1,8 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, timezone from typing import Optional, List
from typing import Optional
from database import get_db from database import get_db
from auth import ( from auth import (
@@ -12,10 +11,12 @@ from auth import (
create_refresh_token, create_refresh_token,
verify_token, verify_token,
get_current_user, get_current_user,
require_admin,
create_default_admin, create_default_admin,
log_action,
) )
from models import User 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"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -87,3 +88,87 @@ def me(user: User = Depends(get_current_user)):
def logout(response: Response): def logout(response: Response):
response.delete_cookie("access_token") response.delete_cookie("access_token")
return {"ok": True} 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}
+93 -5
View File
@@ -7,12 +7,17 @@ from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
from database import get_db from database import get_db
from auth import verify_token from auth import verify_token, require_admin
from models import Computer, Heartbeat, User, Notification from models import Computer, Heartbeat, User, Notification, AuditLog, Setting
from notifications import check_offline_computers, get_unread_count from notifications import check_offline_computers, get_unread_count
from settings_store import get_all_settings
from timezone_utils import localtime, localtime_iso, get_common_timezones, get_current_timezone
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
templates.env.filters["localtime"] = localtime
templates.env.filters["localtime_iso"] = localtime_iso
templates.env.globals["current_timezone"] = get_current_timezone
def user_or_redirect(request: Request, db: Session): def user_or_redirect(request: Request, db: Session):
@@ -25,6 +30,10 @@ def user_or_redirect(request: Request, db: Session):
return db.query(User).filter(User.username == payload["sub"]).first() 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) @router.get("/", response_class=HTMLResponse)
def index( def index(
request: Request, request: Request,
@@ -85,8 +94,10 @@ def index(
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
def login_page(request: Request): def login_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("login.html", {"request": request, "error": None}) # 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) @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 = [] hb_data = []
latest_sessions = []
for h in heartbeats: for h in heartbeats:
hb_data.append({ hb_data.append({
"timestamp": h.timestamp.isoformat() if h.timestamp else None, "timestamp": localtime_iso(h.timestamp),
"username": h.username, "username": h.username,
"local_ip": h.local_ip, "local_ip": h.local_ip,
"cpu_percent": h.cpu_percent, "cpu_percent": h.cpu_percent,
"ram_percent": h.ram_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, "cpu_temp": h.cpu_temp,
"processes": h.processes or [], "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( return templates.TemplateResponse(
"computer.html", "computer.html",
@@ -126,6 +151,7 @@ def computer_page(computer_id: int, request: Request, db: Session = Depends(get_
"user": current_user, "user": current_user,
"computer": computer, "computer": computer,
"heartbeats": hb_data, "heartbeats": hb_data,
"latest_sessions": latest_sessions,
"unread_count": get_unread_count(db), "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), "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),
},
)
+68
View File
@@ -17,6 +17,20 @@ class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True) 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): class Token(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
@@ -32,6 +46,13 @@ class LoginPayload(BaseModel):
password: str 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): class HeartbeatPayload(BaseModel):
hostname: str hostname: str
username: str username: str
@@ -42,10 +63,17 @@ class HeartbeatPayload(BaseModel):
cpu_percent: float cpu_percent: float
ram_percent: float ram_percent: float
ram_total_gb: Optional[float] = None 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 disk_info: Optional[List[dict]] = None
cpu_temp: Optional[float] = None cpu_temp: Optional[float] = None
load_avg: Optional[str] = None load_avg: Optional[str] = None
processes: Optional[List[dict]] = None processes: Optional[List[dict]] = None
sessions: Optional[List[SessionInfo]] = None
class HeartbeatOut(BaseModel): class HeartbeatOut(BaseModel):
@@ -55,7 +83,15 @@ class HeartbeatOut(BaseModel):
local_ip: Optional[str] local_ip: Optional[str]
cpu_percent: Optional[float] cpu_percent: Optional[float]
ram_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] cpu_temp: Optional[float]
sessions: Optional[List[SessionInfo]] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -71,6 +107,13 @@ class ComputerOut(BaseModel):
current_ip: Optional[str] current_ip: Optional[str]
current_cpu_percent: Optional[float] current_cpu_percent: Optional[float]
current_ram_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) model_config = ConfigDict(from_attributes=True)
@@ -90,3 +133,28 @@ class NotificationOut(BaseModel):
read_at: Optional[datetime] read_at: Optional[datetime]
model_config = ConfigDict(from_attributes=True) 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
+53
View File
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (logout) { if (logout) {
logout.addEventListener('click', async (e) => { logout.addEventListener('click', async (e) => {
e.preventDefault(); e.preventDefault();
await fetch('/api/auth/logout', { method: 'POST' }); await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login'; window.location.href = '/login';
}); });
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+4
View File
@@ -6,6 +6,10 @@ body {
font-weight: 600; font-weight: 600;
} }
.navbar-brand img {
vertical-align: middle;
}
.card { .card {
border-radius: 0.75rem; border-radius: 0.75rem;
border: 0; border: 0;
+35
View File
@@ -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 %}
+80
View File
@@ -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 %}
+127
View File
@@ -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 %}
+22 -3
View File
@@ -7,15 +7,35 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script>
window.SERVER_TIMEZONE = "{{ current_timezone() }}";
</script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid"> <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 %} {% 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"> <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"> <li class="nav-item me-3">
<a href="/notifications" class="nav-link position-relative"> <a href="/notifications" class="nav-link position-relative">
<i class="bi bi-bell"></i> <i class="bi bi-bell"></i>
@@ -26,7 +46,6 @@
{% endif %} {% endif %}
</a> </a>
</li> </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> <li class="nav-item"><a class="nav-link" href="/" id="logout-link">Выйти</a></li>
</ul> </ul>
</div> </div>
+100 -6
View File
@@ -23,7 +23,11 @@
<div class="card text-center shadow-sm"> <div class="card text-center shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="text-muted small">Пользователь</div> <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> <div class="fw-bold">{{ computer.current_user or '-' }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -47,6 +51,70 @@
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-md-6"> <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 shadow-sm">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">CPU, %</h5> <h5 class="card-title">CPU, %</h5>
@@ -54,7 +122,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">RAM, %</h5> <h5 class="card-title">RAM, %</h5>
@@ -62,6 +130,14 @@
</div> </div>
</div> </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>
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
@@ -91,13 +167,21 @@
<div class="card-body"> <div class="card-body">
<h5 class="card-title">История heartbeats</h5> <h5 class="card-title">История heartbeats</h5>
<table class="table table-sm"> <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> <tbody>
{% for h in heartbeats[-20:] | reverse %} {% for h in heartbeats[-20:] | reverse %}
<tr> <tr>
<td>{{ h.timestamp[:16] | replace('T', ' ') if h.timestamp else '-' }}</td> <td>{{ h.timestamp[:16] | replace('T', ' ') if h.timestamp else '-' }}</td>
<td>{{ h.cpu_percent | round(1) }}%</td> <td>{{ h.cpu_percent | round(1) }}%</td>
<td>{{ h.ram_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> <td>{{ h.local_ip or '-' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -106,22 +190,32 @@
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %}
<script> <script>
const heartbeats = {{ heartbeats | tojson }}; const heartbeats = {{ heartbeats | tojson }};
const labels = heartbeats.map(h => h.timestamp.replace('T', ' ').slice(0, 16)); const labels = heartbeats.map(h => h.timestamp.replace('T', ' ').slice(0, 16));
const cpuData = heartbeats.map(h => h.cpu_percent); const cpuData = heartbeats.map(h => h.cpu_percent ?? null);
const ramData = heartbeats.map(h => h.ram_percent); const ramData = heartbeats.map(h => h.ram_percent ?? null);
const swapData = heartbeats.map(h => h.swap_percent ?? null);
new Chart(document.getElementById('cpuChart'), { new Chart(document.getElementById('cpuChart'), {
type: 'line', type: 'line',
data: { labels, datasets: [{ label: 'CPU %', data: cpuData, borderColor: 'rgb(255, 99, 132)', tension: 0.2 }] }, 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'), { new Chart(document.getElementById('ramChart'), {
type: 'line', type: 'line',
data: { labels, datasets: [{ label: 'RAM %', data: ramData, borderColor: 'rgb(54, 162, 235)', tension: 0.2 }] }, 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> </script>
{% endblock %} {% endblock %}
+102 -11
View File
@@ -6,9 +6,9 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2>Компьютеры</h2> <h2>Компьютеры</h2>
<div class="d-flex gap-2"> <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 <i class="bi bi-download"></i> Экспорт CSV
</a> </button>
<a href="/notifications" class="btn btn-outline-info btn-sm"> <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 %} <i class="bi bi-bell"></i> Уведомления {% if unread_count %}<span class="badge bg-danger">{{ unread_count }}</span>{% endif %}
</a> </a>
@@ -59,6 +59,7 @@
<th>Статус</th> <th>Статус</th>
<th>CPU</th> <th>CPU</th>
<th>RAM</th> <th>RAM</th>
<th>Swap</th>
<th>OS</th> <th>OS</th>
<th>Последний чек</th> <th>Последний чек</th>
<th></th> <th></th>
@@ -68,7 +69,13 @@
{% for c in computers %} {% for c in computers %}
<tr data-id="{{ c.id }}"> <tr data-id="{{ c.id }}">
<td><strong>{{ c.hostname }}</strong></td> <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>{{ c.current_ip or '-' }}</td>
<td> <td>
{% if c.status == 'online' %} {% if c.status == 'online' %}
@@ -78,10 +85,22 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ c.current_cpu_percent | round(1) if c.current_cpu_percent is not none else '-' }}%</td> <td>{{ c.current_cpu_percent | round(1) if c.current_cpu_percent is not none else '-' }}%</td>
<td>{{ c.current_ram_percent | round(1) if c.current_ram_percent is not none else '-' }}%</td> <td>
{% 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>{{ c.os_info or '-' }}</td>
<td class="last-seen">{% if c.last_seen %}{{ c.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}-{% endif %}</td> <td class="last-seen">{{ c.last_seen | localtime }}</td>
<td><a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a></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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -94,29 +113,101 @@
{% block scripts %} {% block scripts %}
<script> <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() { function currentQuery() {
return new URLSearchParams(window.location.search).toString(); 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() { async function loadDashboard() {
try { try {
const res = await fetch('/api/computers?' + currentQuery()); const res = await fetch('/api/computers?' + currentQuery(), {credentials: 'include'});
if (!res.ok) return location.reload(); if (!res.ok) return location.reload();
const data = await res.json(); const data = await res.json();
const tbody = document.querySelector('#computers-table tbody'); const tbody = document.querySelector('#computers-table tbody');
tbody.innerHTML = data.map(c => ` tbody.innerHTML = data.map(c => `
<tr data-id="${c.id}"> <tr data-id="${c.id}">
<td><strong>${escapeHtml(c.hostname)}</strong></td> <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>${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.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_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>${escapeHtml(c.os_info || '-')}</td>
<td class="last-seen">${c.last_seen ? c.last_seen.replace('T', ' ').slice(0, 16) : '-'}</td> <td class="last-seen">${formatServerTime(c.last_seen)}</td>
<td><a href="/computers/${c.id}" class="btn btn-sm btn-outline-primary">Детали</a></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> </tr>
`).join(''); `).join('');
document.querySelectorAll('.delete-pc').forEach(btn => {
btn.addEventListener('click', () => deleteComputer(btn.dataset.id, btn.dataset.hostname));
});
} catch (e) { } catch (e) {
console.error('Dashboard refresh failed', e); console.error('Dashboard refresh failed', e);
} }
+40 -1
View File
@@ -23,6 +23,21 @@
<div class="alert alert-danger mt-3">{{ error }}</div> <div class="alert alert-danger mt-3">{{ error }}</div>
{% endif %} {% endif %}
<div id="login-error" class="alert alert-danger mt-3 d-none"></div> <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> </div>
<div class="text-center mt-3 text-muted small"> <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('username', document.getElementById('username').value);
form.append('password', document.getElementById('password').value); form.append('password', document.getElementById('password').value);
try { 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) { if (res.ok) {
window.location.href = '/'; window.location.href = '/';
} else { } else {
@@ -55,5 +70,29 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
err.classList.remove('d-none'); 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> </script>
{% endblock %} {% endblock %}
+3 -3
View File
@@ -19,7 +19,7 @@
<div> <div>
<h6 class="card-title mb-1">{{ n.title }}</h6> <h6 class="card-title mb-1">{{ n.title }}</h6>
<p class="card-text mb-1 text-muted small">{{ n.message }}</p> <p class="card-text mb-1 text-muted small">{{ n.message }}</p>
<p class="card-text"><small class="text-muted">{{ n.sent_at.strftime('%Y-%m-%d %H:%M') if n.sent_at else '-' }}</small></p> <p class="card-text"><small class="text-muted">{{ n.sent_at | localtime }}</small></p>
</div> </div>
{% if not n.is_read %} {% if not n.is_read %}
<button class="btn btn-sm btn-outline-success mark-read" data-id="{{ n.id }}">Прочитать</button> <button class="btn btn-sm btn-outline-success mark-read" data-id="{{ n.id }}">Прочитать</button>
@@ -36,7 +36,7 @@
{% block scripts %} {% block scripts %}
<script> <script>
async function markRead(id) { 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(); if (res.ok) location.reload();
} }
@@ -45,7 +45,7 @@ document.querySelectorAll('.mark-read').forEach(btn => {
}); });
document.getElementById('mark-all-read')?.addEventListener('click', async () => { 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(); if (res.ok) location.reload();
}); });
</script> </script>
+69
View File
@@ -0,0 +1,69 @@
"""Timezone helpers for web UI."""
from contextvars import ContextVar
from datetime import datetime
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
_current_timezone: ContextVar[str] = ContextVar("current_timezone", default="UTC")
def set_current_timezone(tz: str) -> None:
_current_timezone.set(tz)
def get_current_timezone() -> str:
return _current_timezone.get()
def get_common_timezones() -> list[str]:
"""Return a curated list of common IANA timezones for the UI."""
return [
"UTC",
"Europe/Moscow",
"Europe/Samara",
"Asia/Krasnoyarsk",
"Asia/Novosibirsk",
"Asia/Irkutsk",
"Asia/Yakutsk",
"Asia/Vladivostok",
"Asia/Kamchatka",
"Asia/Almaty",
"Asia/Tashkent",
"Asia/Tbilisi",
]
def _ensure_aware(dt: datetime, default_tz: str = "UTC") -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=ZoneInfo(default_tz))
return dt
def localtime(
value: Optional[datetime],
tz: Optional[str] = None,
fmt: str = "%Y-%m-%d %H:%M",
) -> str:
"""Convert a UTC datetime to the configured timezone and format it."""
if value is None:
return "-"
tz_name = tz or get_current_timezone()
try:
zone = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
zone = ZoneInfo("UTC")
dt = _ensure_aware(value)
return dt.astimezone(zone).strftime(fmt)
def localtime_iso(value: Optional[datetime], tz: Optional[str] = None) -> str:
"""Convert a UTC datetime to the configured timezone and return ISO string."""
if value is None:
return ""
tz_name = tz or get_current_timezone()
try:
zone = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
zone = ZoneInfo("UTC")
dt = _ensure_aware(value)
return dt.astimezone(zone).isoformat()