Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9018f83b9 | ||
|
|
e1372b0584 | ||
|
|
5e77c162c7 | ||
|
|
49a884c01d | ||
|
|
3b5ac3d282 | ||
|
|
1560592af4 | ||
|
|
76f74201e5 | ||
|
|
513b8ede84 | ||
|
|
559025d5f7 | ||
|
|
7b2e5172ea | ||
|
|
8e62eaf3f3 | ||
|
|
677f6028d9 | ||
|
|
d1128f4aa3 |
@@ -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-ботом (двусторонняя)
|
||||||
- [ ] Ролевая модель для уведомлений (какие роли получают какие типы уведомлений)
|
- [ ] Ролевая модель для уведомлений (какие роли получают какие типы уведомлений)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Массовый деплой InfoUser Agent
|
||||||
|
|
||||||
|
Скрипт `deploy-agent.sh` устанавливает или обновляет агент `info-user-agent` на списке Linux-хостов по SSH.
|
||||||
|
|
||||||
|
## Принцип работы
|
||||||
|
|
||||||
|
1. Пытается подключиться по ключу `id_rsa`.
|
||||||
|
2. Если ключ не подходит — подключается по паролю и копирует публичный ключ в `~/.ssh/authorized_keys` (последующие запуски будут по ключу).
|
||||||
|
3. Устанавливает/обновляет агент:
|
||||||
|
- Сначала `apt-get install info-user-agent` из подключённого apt-репозитория.
|
||||||
|
- Если apt не сработал — fallback через `dpkg -i` из `.deb` в корне проекта.
|
||||||
|
4. Пишет `config.json` с адресом сервера и токеном.
|
||||||
|
5. Запускает systemd-таймер и триггерит первый heartbeat.
|
||||||
|
6. Раскладывает результат в `deploy-ok.txt` / `deploy-deferred.txt` и логи в `logs/`.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
На машине, с которой запускается скрипт:
|
||||||
|
|
||||||
|
- `bash`
|
||||||
|
- `ssh`, `scp`
|
||||||
|
- `sshpass`
|
||||||
|
- `ping`
|
||||||
|
|
||||||
|
На целевых хостах:
|
||||||
|
|
||||||
|
- Доступен SSH-сервер.
|
||||||
|
- Пользователь входит в `sudo` (без пароля на сам sudo запросы идут с паролем).
|
||||||
|
- Подключён apt-репозиторий с пакетом `info-user-agent` (для основного пути установки).
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
### 1. SSH-ключ
|
||||||
|
|
||||||
|
Положите приватный ключ рядом со скриптом:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /path/to/id_rsa ./id_rsa
|
||||||
|
chmod 600 ./id_rsa
|
||||||
|
```
|
||||||
|
|
||||||
|
Публичный ключ `./id_rsa.pub` будет сгенерирован автоматически при первом запуске.
|
||||||
|
|
||||||
|
### 2. Список хостов
|
||||||
|
|
||||||
|
Создайте `ip.txt` (один хост на строку):
|
||||||
|
|
||||||
|
```text
|
||||||
|
user@192.168.255.50
|
||||||
|
user@192.168.255.16
|
||||||
|
192.168.255.71
|
||||||
|
# это комментарий
|
||||||
|
```
|
||||||
|
|
||||||
|
Если пользователь не указан, используется `user`.
|
||||||
|
|
||||||
|
### 3. Параметры подключения
|
||||||
|
|
||||||
|
По умолчанию:
|
||||||
|
|
||||||
|
- SSH-порт: `22`
|
||||||
|
- Пользователь по умолчанию: `user`
|
||||||
|
- Адрес сервера: `http://192.168.255.180:3000`
|
||||||
|
- Токен агента: `a916c8cd656b3f40acc2920820abf88e`
|
||||||
|
|
||||||
|
**Пароль SSH/Sudo запрашивается интерактивно** при запуске скрипта. Чтобы не вводить каждый раз, можно задать переменную окружения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SSH_PASS='1q2w3e$R'
|
||||||
|
export SERVER_URL='http://monitor.example.com:3000'
|
||||||
|
export AGENT_TOKEN='my-token'
|
||||||
|
export SSH_PORT=2222
|
||||||
|
export DEFAULT_USER='admin'
|
||||||
|
./deploy-agent.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
В этом случае пароль не отображается на экране и не сохраняется в истории shell.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/user/moder/info-user/deploy
|
||||||
|
./deploy-agent.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
|
||||||
|
После завершения рядом со скриптом появятся:
|
||||||
|
|
||||||
|
- `deploy-ok.txt` — хосты, на которых всё прошло успешно.
|
||||||
|
- `deploy-deferred.txt` — хосты, которые надо доразворачивать (нет пинга, нет SSH, ошибка установки).
|
||||||
|
- `logs/` — подробные логи по каждому хосту.
|
||||||
|
- `ip.txt.bak.YYYYMMDD_HHMMSS` — бэкап списка хостов.
|
||||||
|
|
||||||
|
Если в `deploy-deferred.txt` что-то есть, исправьте проблему и запустите скрипт снова — он переберёт весь список заново.
|
||||||
|
|
||||||
|
## Установка отдельного хоста вручную
|
||||||
|
|
||||||
|
Если хост не попадает под массовый деплой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На хосте
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y info-user-agent
|
||||||
|
sudo info-user-agent-setup
|
||||||
|
# или отредактируйте /opt/info-user-agent/config.json вручную
|
||||||
|
sudo systemctl start info-user-agent.timer
|
||||||
|
sudo systemctl start info-user-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"SSH недоступен (ключ и пароль)"** — хост пингуется, но не принимает ни ключ, ни пароль. Проверьте пользователя/пароль, настройки SSH (`PermitRootLogin` / `PasswordAuthentication`).
|
||||||
|
- **"Ошибка установки"** — apt-репозиторий недоступен и fallback `.deb` не найден. Положите актуальный `.deb` в корень проекта (`info-user-agent_*.deb`).
|
||||||
|
- **sshpass не найден** — установите:
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y sshpass
|
||||||
|
```
|
||||||
Executable
+395
@@ -0,0 +1,395 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# deploy-agent.sh — массовая установка/обновление InfoUser Agent.
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# cd /home/user/moder/info-user/deploy
|
||||||
|
# ./deploy-agent.sh
|
||||||
|
#
|
||||||
|
# Перед запуском:
|
||||||
|
# 1. Положить id_rsa в ./id_rsa
|
||||||
|
# 2. Создать ./ip.txt со списком хостов (user@IP или просто IP)
|
||||||
|
# 3. При необходимости изменить SERVER_URL/AGENT_TOKEN ниже или через env
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
HOSTS_FILE="${SCRIPT_DIR}/ip.txt"
|
||||||
|
KEY_FILE="${SCRIPT_DIR}/id_rsa"
|
||||||
|
PUB_FILE="${SCRIPT_DIR}/id_rsa.pub"
|
||||||
|
|
||||||
|
OK_FILE="${SCRIPT_DIR}/deploy-ok.txt"
|
||||||
|
DEFER_FILE="${SCRIPT_DIR}/deploy-deferred.txt"
|
||||||
|
LOG_DIR="${SCRIPT_DIR}/logs"
|
||||||
|
|
||||||
|
PKG_NAME="info-user-agent"
|
||||||
|
AGENT_CONFIG_DIR="/opt/info-user-agent"
|
||||||
|
AGENT_CONFIG_FILE="${AGENT_CONFIG_DIR}/config.json"
|
||||||
|
|
||||||
|
SERVER_URL="${SERVER_URL:-http://192.168.255.180:3000}"
|
||||||
|
AGENT_TOKEN="${AGENT_TOKEN:-a916c8cd656b3f40acc2920820abf88e}"
|
||||||
|
SSH_PORT="${SSH_PORT:-22}"
|
||||||
|
DEFAULT_USER="${DEFAULT_USER:-user}"
|
||||||
|
MAX_RETRIES="${MAX_RETRIES:-3}"
|
||||||
|
RETRY_SLEEP="${RETRY_SLEEP:-3}"
|
||||||
|
|
||||||
|
# Пароль SSH/Sudo запрашивается интерактивно, если не задан через env.
|
||||||
|
# Пример: SSH_PASS='MyPass' ./deploy-agent.sh
|
||||||
|
if [[ -z "${SSH_PASS:-}" ]]; then
|
||||||
|
read -rsp "SSH/Sudo пароль: " SSH_PASS
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Цвета
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
SSH_KEY_OPTS=(
|
||||||
|
-o BatchMode=yes
|
||||||
|
-o StrictHostKeyChecking=accept-new
|
||||||
|
-o ConnectTimeout=6
|
||||||
|
-o ConnectionAttempts=1
|
||||||
|
-o ServerAliveInterval=5
|
||||||
|
-o ServerAliveCountMax=1
|
||||||
|
-o PasswordAuthentication=no
|
||||||
|
-p "$SSH_PORT"
|
||||||
|
)
|
||||||
|
|
||||||
|
SSH_PASS_OPTS=(
|
||||||
|
-o StrictHostKeyChecking=accept-new
|
||||||
|
-o ConnectTimeout=6
|
||||||
|
-o ConnectionAttempts=1
|
||||||
|
-o ServerAliveInterval=5
|
||||||
|
-o ServerAliveCountMax=1
|
||||||
|
-p "$SSH_PORT"
|
||||||
|
)
|
||||||
|
|
||||||
|
SCP_KEY_OPTS=(
|
||||||
|
-i "$KEY_FILE"
|
||||||
|
-o BatchMode=yes
|
||||||
|
-o StrictHostKeyChecking=accept-new
|
||||||
|
-o ConnectTimeout=6
|
||||||
|
-o ConnectionAttempts=1
|
||||||
|
-p "$SSH_PORT"
|
||||||
|
)
|
||||||
|
|
||||||
|
SCP_PASS_OPTS=(
|
||||||
|
-o StrictHostKeyChecking=accept-new
|
||||||
|
-o ConnectTimeout=6
|
||||||
|
-o ConnectionAttempts=1
|
||||||
|
-p "$SSH_PORT"
|
||||||
|
)
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo -e "${BLUE}=== InfoUser Agent deploy ===${NC}"
|
||||||
|
echo "SERVER_URL: $SERVER_URL"
|
||||||
|
echo "AGENT_TOKEN: ${AGENT_TOKEN:0:8}..."
|
||||||
|
echo "HOSTS_FILE: $HOSTS_FILE"
|
||||||
|
echo "KEY_FILE: $KEY_FILE"
|
||||||
|
echo "LOG_DIR: $LOG_DIR"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
check_prerequisites() {
|
||||||
|
local missing=()
|
||||||
|
command -v ssh >/dev/null 2>&1 || missing+=("ssh")
|
||||||
|
command -v scp >/dev/null 2>&1 || missing+=("scp")
|
||||||
|
command -v sshpass >/dev/null 2>&1 || missing+=("sshpass")
|
||||||
|
command -v ping >/dev/null 2>&1 || missing+=("ping")
|
||||||
|
|
||||||
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||||
|
echo -e "${RED}Ошибка: не найдены утилиты: ${missing[*]}${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$HOSTS_FILE" ]]; then
|
||||||
|
echo -e "${RED}Ошибка: файл со списком хостов не найден: $HOSTS_FILE${NC}" >&2
|
||||||
|
echo "Создайте его по примеру ip.txt.example" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$KEY_FILE" ]]; then
|
||||||
|
echo -e "${RED}Ошибка: SSH-ключ не найден: $KEY_FILE${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Генерируем .pub если нет
|
||||||
|
if [[ ! -f "$PUB_FILE" ]]; then
|
||||||
|
echo "Генерирую публичный ключ: $PUB_FILE"
|
||||||
|
ssh-keygen -y -f "$KEY_FILE" > "$PUB_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ищем fallback .deb в корне проекта
|
||||||
|
DEB_FILE=""
|
||||||
|
for f in "${PROJECT_DIR}"/${PKG_NAME}_*_all.deb; do
|
||||||
|
[[ -f "$f" ]] && DEB_FILE="$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$DEB_FILE" ]]; then
|
||||||
|
echo -e "${YELLOW}Предупреждение: fallback .deb не найден в $PROJECT_DIR. Будет использован только apt.${NC}"
|
||||||
|
else
|
||||||
|
echo "Fallback .deb: $DEB_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_target() {
|
||||||
|
local line="$1"
|
||||||
|
if [[ "$line" == *"@"* ]]; then
|
||||||
|
echo "$line"
|
||||||
|
else
|
||||||
|
echo "${DEFAULT_USER}@${line}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_host() {
|
||||||
|
local target="$1"
|
||||||
|
echo "${target#*@}"
|
||||||
|
}
|
||||||
|
|
||||||
|
safe_name() {
|
||||||
|
local target="$1"
|
||||||
|
echo "${target//[@.:\/]/_}"
|
||||||
|
}
|
||||||
|
|
||||||
|
with_retries() {
|
||||||
|
local desc="$1"
|
||||||
|
shift
|
||||||
|
local attempt=1
|
||||||
|
while true; do
|
||||||
|
if "$@"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if (( attempt >= MAX_RETRIES )); then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo " ${desc}: повтор ${attempt}/${MAX_RETRIES}..."
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep "$RETRY_SLEEP"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ping_check() {
|
||||||
|
local host="$1"
|
||||||
|
ping -c 1 -W 1 "$host" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_key_check() {
|
||||||
|
local target="$1"
|
||||||
|
ssh -i "$KEY_FILE" -n "${SSH_KEY_OPTS[@]}" "$target" "echo ok" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_pass_check() {
|
||||||
|
local target="$1"
|
||||||
|
sshpass -p "$SSH_PASS" ssh -n "${SSH_PASS_OPTS[@]}" "$target" "echo ok" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_auth_method() {
|
||||||
|
local target="$1"
|
||||||
|
if ssh_key_check "$target"; then
|
||||||
|
echo "key"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ssh_pass_check "$target"; then
|
||||||
|
echo "pass"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "fail"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_key_on_host() {
|
||||||
|
local target="$1"
|
||||||
|
local log_file="$2"
|
||||||
|
{
|
||||||
|
echo "==> SSH по паролю успешен, добавляю ключ в authorized_keys"
|
||||||
|
sshpass -p "$SSH_PASS" ssh -n "${SSH_PASS_OPTS[@]}" "$target" "mkdir -p ~/.ssh && chmod 700 ~/.ssh"
|
||||||
|
sshpass -p "$SSH_PASS" scp "${SCP_PASS_OPTS[@]}" "$PUB_FILE" "$target:/tmp/info_user_key.pub"
|
||||||
|
sshpass -p "$SSH_PASS" ssh -n "${SSH_PASS_OPTS[@]}" "$target" "cat /tmp/info_user_key.pub >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && rm -f /tmp/info_user_key.pub"
|
||||||
|
} >>"$log_file" 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_exec() {
|
||||||
|
local method="$1"
|
||||||
|
local target="$2"
|
||||||
|
local cmd="$3"
|
||||||
|
if [[ "$method" == "key" ]]; then
|
||||||
|
ssh -i "$KEY_FILE" -n "${SSH_KEY_OPTS[@]}" "$target" "$cmd"
|
||||||
|
else
|
||||||
|
sshpass -p "$SSH_PASS" ssh -n "${SSH_PASS_OPTS[@]}" "$target" "$cmd"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_to_host() {
|
||||||
|
local method="$1"
|
||||||
|
local target="$2"
|
||||||
|
local local_path="$3"
|
||||||
|
local remote_path="$4"
|
||||||
|
if [[ "$method" == "key" ]]; then
|
||||||
|
scp "${SCP_KEY_OPTS[@]}" "$local_path" "$target:$remote_path"
|
||||||
|
else
|
||||||
|
sshpass -p "$SSH_PASS" scp "${SCP_PASS_OPTS[@]}" "$local_path" "$target:$remote_path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_agent() {
|
||||||
|
local method="$1"
|
||||||
|
local target="$2"
|
||||||
|
local log_file="$3"
|
||||||
|
|
||||||
|
local config_json
|
||||||
|
config_json=$(printf '{"server_url":"%s","agent_token":"%s","interval_minutes":60,"send_public_ip":false}' "$SERVER_URL" "$AGENT_TOKEN")
|
||||||
|
|
||||||
|
local remote_cmd
|
||||||
|
remote_cmd=$(printf 'echo "%s" | sudo -S tee %s >/dev/null' "$config_json" "$AGENT_CONFIG_FILE")
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "==> Настраиваю $AGENT_CONFIG_FILE"
|
||||||
|
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S mkdir -p '$AGENT_CONFIG_DIR'"
|
||||||
|
ssh_exec "$method" "$target" "$remote_cmd"
|
||||||
|
|
||||||
|
echo "==> Запускаю таймер и первый heartbeat"
|
||||||
|
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S systemctl daemon-reload"
|
||||||
|
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S systemctl enable info-user-agent.timer"
|
||||||
|
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S systemctl start info-user-agent.timer"
|
||||||
|
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S systemctl start info-user-agent"
|
||||||
|
} >>"$log_file" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_agent() {
|
||||||
|
local method="$1"
|
||||||
|
local target="$2"
|
||||||
|
local log_file="$3"
|
||||||
|
|
||||||
|
# Сначала пробуем apt
|
||||||
|
{
|
||||||
|
echo "==> Пробую установить через apt"
|
||||||
|
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S apt-get update -qq"
|
||||||
|
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S apt-get install -y '$PKG_NAME'"
|
||||||
|
} >>"$log_file" 2>&1 && return 0
|
||||||
|
|
||||||
|
# Fallback на dpkg если apt не сработал и есть .deb
|
||||||
|
if [[ -z "$DEB_FILE" ]]; then
|
||||||
|
echo " apt не сработал, fallback .deb отсутствует" >>"$log_file" 2>&1
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local deb_name
|
||||||
|
deb_name="$(basename "$DEB_FILE")"
|
||||||
|
local remote_deb="/tmp/${deb_name}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "==> apt не сработал, пробую dpkg -i $deb_name"
|
||||||
|
scp_to_host "$method" "$target" "$DEB_FILE" "$remote_deb"
|
||||||
|
ssh_exec "$method" "$target" "echo '$SSH_PASS' | sudo -S dpkg -i '$remote_deb'"
|
||||||
|
ssh_exec "$method" "$target" "rm -f '$remote_deb'"
|
||||||
|
} >>"$log_file" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
process_host() {
|
||||||
|
local raw_line="$1"
|
||||||
|
local target
|
||||||
|
local host_only
|
||||||
|
local safe_host
|
||||||
|
local log_file
|
||||||
|
local auth_method
|
||||||
|
|
||||||
|
target="$(normalize_target "$raw_line")"
|
||||||
|
host_only="$(extract_host "$target")"
|
||||||
|
safe_host="$(safe_name "$target")"
|
||||||
|
log_file="${LOG_DIR}/${safe_host}.log"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}===== $target =====${NC}"
|
||||||
|
|
||||||
|
# Пинг
|
||||||
|
if ! with_retries "ping" ping_check "$host_only"; then
|
||||||
|
echo -e "${RED}❌ Пинг не проходит: $target -> отложен${NC}"
|
||||||
|
echo "$target" >> "$DEFER_FILE"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Определяем метод авторизации
|
||||||
|
auth_method="$(detect_auth_method "$target")"
|
||||||
|
if [[ "$auth_method" == "fail" ]]; then
|
||||||
|
echo -e "${RED}❌ SSH недоступен (ключ и пароль): $target -> отложен${NC}"
|
||||||
|
echo "$target" >> "$DEFER_FILE"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$auth_method" == "key" ]]; then
|
||||||
|
echo -e "${GREEN}✅ SSH по ключу: $target${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✅ SSH по паролю: $target (добавляю ключ)${NC}"
|
||||||
|
install_key_on_host "$target" "$log_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Установка/обновление
|
||||||
|
if install_agent "$auth_method" "$target" "$log_file"; then
|
||||||
|
echo -e "${GREEN}✅ Агент установлен/обновлён: $target${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Ошибка установки: $target -> отложен (см. $log_file)${NC}"
|
||||||
|
echo "$target" >> "$DEFER_FILE"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Настройка и запуск
|
||||||
|
if configure_agent "$auth_method" "$target" "$log_file"; then
|
||||||
|
echo -e "${GREEN}✅ Настроен и запущен: $target${NC}"
|
||||||
|
echo "$target" >> "$OK_FILE"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Установлен, но не удалось настроить/запустить: $target -> отложен (см. $log_file)${NC}"
|
||||||
|
echo "$target" >> "$DEFER_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
local ok_count
|
||||||
|
local defer_count
|
||||||
|
ok_count="$(wc -l < "$OK_FILE" 2>/dev/null || echo 0)"
|
||||||
|
defer_count="$(wc -l < "$DEFER_FILE" 2>/dev/null || echo 0)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}=== Итог ===${NC}"
|
||||||
|
echo -e "${GREEN}Успешно: $ok_count${NC}"
|
||||||
|
echo -e "${RED}Отложено: $defer_count${NC}"
|
||||||
|
echo "Логи: $LOG_DIR/"
|
||||||
|
if [[ -s "$DEFER_FILE" ]]; then
|
||||||
|
echo "Повторить для отложенных: ./deploy-agent.sh"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
print_header
|
||||||
|
check_prerequisites
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
: > "$OK_FILE"
|
||||||
|
: > "$DEFER_FILE"
|
||||||
|
|
||||||
|
# Бэкап ip.txt
|
||||||
|
cp -f "$HOSTS_FILE" "${HOSTS_FILE}.bak.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true
|
||||||
|
|
||||||
|
local total=0
|
||||||
|
total="$(grep -vE '^\s*$|^\s*#' "$HOSTS_FILE" | wc -l)"
|
||||||
|
echo -e "${BLUE}Хостов в списке: $total${NC}"
|
||||||
|
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
# Убираем CR (Windows-концы строк)
|
||||||
|
line="${line//$'\r'/}"
|
||||||
|
|
||||||
|
# trim
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
line="${line%"${line##*[![:space:]]}"}"
|
||||||
|
|
||||||
|
[[ -z "$line" || "$line" =~ ^# ]] && continue
|
||||||
|
process_host "$line"
|
||||||
|
done < "$HOSTS_FILE"
|
||||||
|
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Список хостов для массового деплоя InfoUser Agent.
|
||||||
|
# Формат: user@IP или просто IP (будет использован DEFAULT_USER).
|
||||||
|
# Пустые строки и строки, начинающиеся с #, игнорируются.
|
||||||
|
|
||||||
|
user@192.168.255.50
|
||||||
|
user@192.168.255.16
|
||||||
|
192.168.255.71
|
||||||
Binary file not shown.
Binary file not shown.
+18
-1
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Optional
|
from 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models import Setting
|
||||||
|
from config import get_settings
|
||||||
|
|
||||||
|
_settings_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(db: Session, key: str) -> Optional[str]:
|
||||||
|
"""Get setting from DB, fallback to .env config."""
|
||||||
|
s = db.query(Setting).filter(Setting.key == key).first()
|
||||||
|
if s:
|
||||||
|
return s.value
|
||||||
|
|
||||||
|
config = get_settings()
|
||||||
|
return getattr(config, key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(db: Session, key: str, value: Optional[str]) -> Setting:
|
||||||
|
s = db.query(Setting).filter(Setting.key == key).first()
|
||||||
|
if s:
|
||||||
|
s.value = value
|
||||||
|
else:
|
||||||
|
s = Setting(key=key, value=value)
|
||||||
|
db.add(s)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
_settings_cache[key] = value
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_settings(db: Session) -> dict:
|
||||||
|
"""Return merged DB settings and .env defaults."""
|
||||||
|
config = get_settings()
|
||||||
|
result = {}
|
||||||
|
keys = [
|
||||||
|
"AGENT_TOKEN",
|
||||||
|
"OFFLINE_THRESHOLD_MINUTES",
|
||||||
|
"NOTIFICATION_WEBHOOK_URL",
|
||||||
|
"NOTIFICATION_TELEGRAM_BOT_TOKEN",
|
||||||
|
"NOTIFICATION_TELEGRAM_CHAT_ID",
|
||||||
|
"TIMEZONE",
|
||||||
|
]
|
||||||
|
for key in keys:
|
||||||
|
db_val = db.query(Setting).filter(Setting.key == key).first()
|
||||||
|
value = db_val.value if db_val else getattr(config, key, None)
|
||||||
|
if key == "OFFLINE_THRESHOLD_MINUTES" and value is not None:
|
||||||
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = getattr(config, key, None)
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
@@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (logout) {
|
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 |
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Аудит-лог — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Аудит-лог</h2>
|
||||||
|
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th>Детали</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in audit %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">{{ a.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td>{{ a.username or '-' }}</td>
|
||||||
|
<td><span class="badge bg-info">{{ a.action }}</span></td>
|
||||||
|
<td class="text-muted small">{{ a.details or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Настройки — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Настройки</h2>
|
||||||
|
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">AGENT_TOKEN</label>
|
||||||
|
<input type="text" class="form-control" id="AGENT_TOKEN" value="{{ settings.AGENT_TOKEN or '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">OFFLINE_THRESHOLD_MINUTES</label>
|
||||||
|
<input type="number" class="form-control" id="OFFLINE_THRESHOLD_MINUTES" value="{{ settings.OFFLINE_THRESHOLD_MINUTES or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">NOTIFICATION_WEBHOOK_URL</label>
|
||||||
|
<input type="text" class="form-control" id="NOTIFICATION_WEBHOOK_URL" value="{{ settings.NOTIFICATION_WEBHOOK_URL or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">NOTIFICATION_TELEGRAM_BOT_TOKEN</label>
|
||||||
|
<input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_BOT_TOKEN" value="{{ settings.NOTIFICATION_TELEGRAM_BOT_TOKEN or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">NOTIFICATION_TELEGRAM_CHAT_ID</label>
|
||||||
|
<input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_CHAT_ID" value="{{ settings.NOTIFICATION_TELEGRAM_CHAT_ID or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Часовой пояс</label>
|
||||||
|
<select class="form-select" id="TIMEZONE">
|
||||||
|
{% for tz in timezones %}
|
||||||
|
<option value="{{ tz }}" {% if settings.TIMEZONE == tz %}selected{% endif %}>{{ tz }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">UTC хранится как есть, в веб-интерфейсе время отображается в выбранном поясе.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<div id="settings-error" class="alert alert-danger mt-3 d-none"></div>
|
||||||
|
<div id="settings-success" class="alert alert-success mt-3 d-none">Настройки сохранены</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const err = document.getElementById('settings-error');
|
||||||
|
const ok = document.getElementById('settings-success');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
ok.classList.add('d-none');
|
||||||
|
const body = {
|
||||||
|
AGENT_TOKEN: document.getElementById('AGENT_TOKEN').value,
|
||||||
|
OFFLINE_THRESHOLD_MINUTES: parseInt(document.getElementById('OFFLINE_THRESHOLD_MINUTES').value),
|
||||||
|
NOTIFICATION_WEBHOOK_URL: document.getElementById('NOTIFICATION_WEBHOOK_URL').value,
|
||||||
|
NOTIFICATION_TELEGRAM_BOT_TOKEN: document.getElementById('NOTIFICATION_TELEGRAM_BOT_TOKEN').value,
|
||||||
|
NOTIFICATION_TELEGRAM_CHAT_ID: document.getElementById('NOTIFICATION_TELEGRAM_CHAT_ID').value,
|
||||||
|
TIMEZONE: document.getElementById('TIMEZONE').value,
|
||||||
|
};
|
||||||
|
const res = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
ok.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
err.textContent = data.detail || 'Ошибка';
|
||||||
|
err.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Управление пользователями — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Пользователи</h2>
|
||||||
|
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Создать пользователя</h5>
|
||||||
|
<form id="create-user-form" class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="text" class="form-control" id="new-username" placeholder="Логин" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="password" class="form-control" id="new-password" placeholder="Пароль" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select class="form-select" id="new-role">
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Создать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="create-error" class="alert alert-danger mt-3 d-none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Логин</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr data-id="{{ u.id }}">
|
||||||
|
<td>{{ u.id }}</td>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if u.role == 'admin' %}bg-danger{% else %}bg-secondary{% endif %}">{{ u.role }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ u.created_at | localtime }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary change-role" data-id="{{ u.id }}" data-username="{{ u.username }}" data-role="{{ u.role }}">Роль</button>
|
||||||
|
<button class="btn btn-sm btn-outline-warning reset-password" data-id="{{ u.id }}" data-username="{{ u.username }}">Пароль</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger delete-user" data-id="{{ u.id }}" data-username="{{ u.username }}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const err = document.getElementById('create-error');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
const res = await fetch('/api/auth/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('new-username').value,
|
||||||
|
password: document.getElementById('new-password').value,
|
||||||
|
role: document.getElementById('new-role').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
err.textContent = data.detail || 'Ошибка';
|
||||||
|
err.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.change-role').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const newRole = btn.dataset.role === 'admin' ? 'viewer' : 'admin';
|
||||||
|
if (!confirm(`Сменить роль ${btn.dataset.username} на ${newRole}?`)) return;
|
||||||
|
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({role: newRole}),
|
||||||
|
});
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
else alert('Ошибка смены роли');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.reset-password').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const password = prompt(`Новый пароль для ${btn.dataset.username}:`);
|
||||||
|
if (!password) return;
|
||||||
|
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({password}),
|
||||||
|
});
|
||||||
|
if (res.ok) alert('Пароль изменён');
|
||||||
|
else alert('Ошибка смены пароля');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.delete-user').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Удалить пользователя ${btn.dataset.username}?`)) return;
|
||||||
|
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {method: 'DELETE', credentials: 'include'});
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
else alert('Ошибка удаления');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,15 +7,35 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link 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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
+103
-12
@@ -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,16 +59,23 @@
|
|||||||
<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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Timezone helpers for web UI."""
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
_current_timezone: ContextVar[str] = ContextVar("current_timezone", default="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_timezone(tz: str) -> None:
|
||||||
|
_current_timezone.set(tz)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_timezone() -> str:
|
||||||
|
return _current_timezone.get()
|
||||||
|
|
||||||
|
|
||||||
|
def get_common_timezones() -> list[str]:
|
||||||
|
"""Return a curated list of common IANA timezones for the UI."""
|
||||||
|
return [
|
||||||
|
"UTC",
|
||||||
|
"Europe/Moscow",
|
||||||
|
"Europe/Samara",
|
||||||
|
"Asia/Krasnoyarsk",
|
||||||
|
"Asia/Novosibirsk",
|
||||||
|
"Asia/Irkutsk",
|
||||||
|
"Asia/Yakutsk",
|
||||||
|
"Asia/Vladivostok",
|
||||||
|
"Asia/Kamchatka",
|
||||||
|
"Asia/Almaty",
|
||||||
|
"Asia/Tashkent",
|
||||||
|
"Asia/Tbilisi",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_aware(dt: datetime, default_tz: str = "UTC") -> datetime:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=ZoneInfo(default_tz))
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def localtime(
|
||||||
|
value: Optional[datetime],
|
||||||
|
tz: Optional[str] = None,
|
||||||
|
fmt: str = "%Y-%m-%d %H:%M",
|
||||||
|
) -> str:
|
||||||
|
"""Convert a UTC datetime to the configured timezone and format it."""
|
||||||
|
if value is None:
|
||||||
|
return "-"
|
||||||
|
tz_name = tz or get_current_timezone()
|
||||||
|
try:
|
||||||
|
zone = ZoneInfo(tz_name)
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
zone = ZoneInfo("UTC")
|
||||||
|
dt = _ensure_aware(value)
|
||||||
|
return dt.astimezone(zone).strftime(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def localtime_iso(value: Optional[datetime], tz: Optional[str] = None) -> str:
|
||||||
|
"""Convert a UTC datetime to the configured timezone and return ISO string."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
tz_name = tz or get_current_timezone()
|
||||||
|
try:
|
||||||
|
zone = ZoneInfo(tz_name)
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
zone = ZoneInfo("UTC")
|
||||||
|
dt = _ensure_aware(value)
|
||||||
|
return dt.astimezone(zone).isoformat()
|
||||||
Reference in New Issue
Block a user