Initial commit: InfoUser Monitor with FastAPI, agent, Docker, JWT auth, notifications, filters and CSV export
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
SECRET_KEY=change-me-in-production
|
||||||
|
AGENT_TOKEN=agent-secret-token
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
HTTP_PORT=80
|
||||||
|
OFFLINE_THRESHOLD_MINUTES=130
|
||||||
|
NOTIFICATION_WEBHOOK_URL=
|
||||||
|
NOTIFICATION_TELEGRAM_BOT_TOKEN=
|
||||||
|
NOTIFICATION_TELEGRAM_CHAT_ID=
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
data/
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# InfoUser Monitor
|
||||||
|
|
||||||
|
Мониторинг состояния ПК (Windows/Linux/macOS). Агент на каждом компьютере отправляет метрики на сервер раз в час, а веб-интерфейс показывает список машин, пользователей, IP, CPU/RAM, диски и процессы.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Дашборд** с фильтрами по статусу, пользователю и поиском по хосту/IP/OS
|
||||||
|
- **Графики** CPU/RAM за 24 часа на странице ПК
|
||||||
|
- **Уведомления** об офлайн-ПК (in-app + webhook + Telegram)
|
||||||
|
- **Экспорт** списка ПК в CSV
|
||||||
|
- **JWT-авторизация** с ролями admin/viewer
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
info-user/
|
||||||
|
├── server/ # FastAPI + веб-интерфейс
|
||||||
|
│ ├── routes/
|
||||||
|
│ ├── templates/
|
||||||
|
│ ├── static/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── main.py
|
||||||
|
├── agent/ # Python-агент
|
||||||
|
│ ├── agent.py
|
||||||
|
│ ├── config.json
|
||||||
|
│ └── install.sh
|
||||||
|
├── data/ # SQLite БД (создаётся при запуске)
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── nginx.conf
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
1. **Клонировать/скопировать проект** и перейти в папку:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/user/moder/info-user
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Создать файл окружения** (опционально, но рекомендуется):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .env <<EOF
|
||||||
|
SECRET_KEY=your-very-secret-key-here
|
||||||
|
AGENT_TOKEN=your-agent-token-here
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
HTTP_PORT=80
|
||||||
|
OFFLINE_THRESHOLD_MINUTES=130
|
||||||
|
NOTIFICATION_WEBHOOK_URL=
|
||||||
|
NOTIFICATION_TELEGRAM_BOT_TOKEN=
|
||||||
|
NOTIFICATION_TELEGRAM_CHAT_ID=
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Запустить сервер**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Открыть веб-интерфейс**:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
Логин/пароль по умолчанию: `admin` / `admin`.
|
||||||
|
|
||||||
|
5. **Установить агент** на клиентский ПК (Linux):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent
|
||||||
|
sudo bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Не забудьте отредактировать `agent/config.json`, указав `server_url` и `agent_token`.
|
||||||
|
|
||||||
|
## Настройка агента
|
||||||
|
|
||||||
|
Файл `agent/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_url": "http://your-server-ip",
|
||||||
|
"agent_token": "your-agent-token-here",
|
||||||
|
"interval_minutes": 60,
|
||||||
|
"send_public_ip": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Собираемые данные
|
||||||
|
|
||||||
|
- Имя хоста, текущий пользователь, локальный IP
|
||||||
|
- Операционная система
|
||||||
|
- Uptime
|
||||||
|
- Загрузка CPU и RAM
|
||||||
|
- Информация по дискам
|
||||||
|
- Температура CPU (если доступна)
|
||||||
|
- Top-10 процессов по CPU
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `POST /api/heartbeat` — приём данных от агента
|
||||||
|
- `GET /api/computers` — список компьютеров (с фильтрами `status`, `user`, `search`, `sort`, `order`)
|
||||||
|
- `GET /api/computers/export/csv` — экспорт в CSV
|
||||||
|
- `GET /api/computers/{id}` — детали ПК с историей
|
||||||
|
- `GET /api/notifications` — уведомления
|
||||||
|
- `POST /api/notifications/{id}/read` — отметить прочитанным
|
||||||
|
- `POST /api/notifications/read-all` — отметить все прочитанными
|
||||||
|
- `POST /api/auth/login` — вход в веб-интерфейс
|
||||||
|
- `POST /api/auth/register` — регистрация нового пользователя (viewer)
|
||||||
|
|
||||||
|
## Уведомления об офлайн-ПК
|
||||||
|
|
||||||
|
ПК считается офлайн, если не присылал heartbeat в течение `OFFLINE_THRESHOLD_MINUTES` минут.
|
||||||
|
|
||||||
|
Доступные каналы:
|
||||||
|
|
||||||
|
- **In-app** — всегда, список уведомлений в веб-интерфейсе
|
||||||
|
- **Webhook** — отправка POST JSON на `NOTIFICATION_WEBHOOK_URL`
|
||||||
|
- **Telegram** — отправка сообщения в чат, если указаны `NOTIFICATION_TELEGRAM_BOT_TOKEN` и `NOTIFICATION_TELEGRAM_CHAT_ID`
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Смените `SECRET_KEY` и `AGENT_TOKEN` в `.env`.
|
||||||
|
- Используйте HTTPS в production (nginx + certbot).
|
||||||
|
- Ограничьте доступ к `/api/heartbeat` по IP или сети.
|
||||||
|
- Пользователи с ролью `viewer` могут только смотреть дашборд.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Python 3.11+ (для локального запуска без Docker)
|
||||||
|
- Linux на клиентских ПК для systemd-таймера (для Windows можно запускать через Task Scheduler)
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT / как есть.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# InfoUser Agent
|
||||||
|
|
||||||
|
Агент собирает базовые метрики компьютера и отправляет их на сервер InfoUser Monitor каждый час.
|
||||||
|
|
||||||
|
## Установка (Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent
|
||||||
|
sudo bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Установка выполняет следующие действия:
|
||||||
|
|
||||||
|
1. Копирует файлы `agent.py`, `config.json`, `requirements.txt` в `/opt/info-user-agent/`.
|
||||||
|
2. Устанавливает Python-зависимости.
|
||||||
|
3. Создаёт `systemd` сервис `info-user-agent.service` и таймер `info-user-agent.timer`.
|
||||||
|
4. Запускает таймер и включает его автозапуск при загрузке.
|
||||||
|
|
||||||
|
Проверка статуса:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status info-user-agent.timer
|
||||||
|
sudo systemctl list-timers info-user-agent.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ручной запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Файл `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_url": "http://your-server-ip-or-hostname",
|
||||||
|
"agent_token": "your-agent-token-here",
|
||||||
|
"interval_minutes": 60,
|
||||||
|
"send_public_ip": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `server_url` — адрес сервера InfoUser Monitor.
|
||||||
|
- `agent_token` — токен, должен совпадать с `AGENT_TOKEN` на сервере.
|
||||||
|
- `interval_minutes` — интервал отправки (при использовании Task Scheduler / cron вручную).
|
||||||
|
- `send_public_ip` — отправлять ли публичный IP (`true`/`false`).
|
||||||
|
|
||||||
|
## Собираемые данные
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| `hostname` | Имя компьютера |
|
||||||
|
| `username` | Имя текущего пользователя |
|
||||||
|
| `os_info` | Операционная система |
|
||||||
|
| `local_ip` | Локальный IP адрес |
|
||||||
|
| `public_ip` | Публичный IP (опционально) |
|
||||||
|
| `uptime_sec` | Время работы с момента загрузки |
|
||||||
|
| `cpu_percent` | Загрузка CPU, % |
|
||||||
|
| `ram_percent` | Загрузка RAM, % |
|
||||||
|
| `ram_total_gb` | Объём RAM в GB |
|
||||||
|
| `disk_info` | Список дисков с использованием |
|
||||||
|
| `cpu_temp` | Температура CPU, °C |
|
||||||
|
| `load_avg` | Средняя загрузка (Linux) |
|
||||||
|
| `processes` | Top-10 процессов по CPU |
|
||||||
|
|
||||||
|
## Установка на Windows
|
||||||
|
|
||||||
|
1. Установите Python 3.11+ и зависимости:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Отредактируйте `config.json`.
|
||||||
|
|
||||||
|
3. Создайте задачу в Task Scheduler:
|
||||||
|
|
||||||
|
- **Trigger**: Daily, repeat every 1 hour.
|
||||||
|
- **Action**: `python.exe` с аргументом `C:\path\to\agent.py`.
|
||||||
|
- **Run with highest privileges** (опционально, для сбора данных всех пользователей).
|
||||||
|
|
||||||
|
## Установка на macOS
|
||||||
|
|
||||||
|
Аналогично Linux, но вместо systemd можно использовать `launchd` plist или cron:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
0 * * * * /usr/bin/python3 /opt/info-user-agent/agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логи и отладка
|
||||||
|
|
||||||
|
При ручном запуске ошибки выводятся в консоль. При установке через systemd журнал доступен:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u info-user-agent.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Храните `agent_token` в секрете и не выкладывайте в публичные репозитории.
|
||||||
|
- Используйте HTTPS для `server_url` в production.
|
||||||
|
- Для публичного IP-адреса включайте `send_public_ip` только при необходимости.
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
import psutil
|
||||||
|
import socket
|
||||||
|
import platform
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
cfg_path = Path(__file__).with_suffix("").parent / "config.json"
|
||||||
|
if cfg_path.exists():
|
||||||
|
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {
|
||||||
|
"server_url": "http://127.0.0.1",
|
||||||
|
"agent_token": "agent-secret-token",
|
||||||
|
"interval_minutes": 60,
|
||||||
|
"send_public_ip": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_ip():
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(1)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_ip():
|
||||||
|
try:
|
||||||
|
r = requests.get("https://api.ipify.org", timeout=5)
|
||||||
|
return r.text.strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_uptime():
|
||||||
|
return int(time.time() - psutil.boot_time())
|
||||||
|
|
||||||
|
|
||||||
|
def get_disk_info():
|
||||||
|
disks = []
|
||||||
|
for part in psutil.disk_partitions(all=False):
|
||||||
|
try:
|
||||||
|
usage = psutil.disk_usage(part.mountpoint)
|
||||||
|
disks.append({
|
||||||
|
"device": part.device,
|
||||||
|
"mountpoint": part.mountpoint,
|
||||||
|
"total_gb": round(usage.total / (2**30), 1),
|
||||||
|
"used_gb": round(usage.used / (2**30), 1),
|
||||||
|
"free_gb": round(usage.free / (2**30), 1),
|
||||||
|
"percent": usage.percent,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return disks
|
||||||
|
|
||||||
|
|
||||||
|
def get_cpu_temp():
|
||||||
|
try:
|
||||||
|
temps = psutil.sensors_temperatures()
|
||||||
|
if temps:
|
||||||
|
for name, entries in temps.items():
|
||||||
|
for entry in entries:
|
||||||
|
if entry.current:
|
||||||
|
return round(entry.current, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_load_avg():
|
||||||
|
try:
|
||||||
|
return ", ".join(str(x) for x in os.getloadavg())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_top_processes(limit=10):
|
||||||
|
procs = []
|
||||||
|
for p in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]):
|
||||||
|
try:
|
||||||
|
procs.append({
|
||||||
|
"pid": p.info["pid"],
|
||||||
|
"name": p.info["name"],
|
||||||
|
"cpu_percent": round(p.info["cpu_percent"] or 0, 1),
|
||||||
|
"ram_percent": round((p.info["memory_percent"] or 0), 1),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
procs.sort(key=lambda x: x["cpu_percent"], reverse=True)
|
||||||
|
return procs[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def collect():
|
||||||
|
cfg = load_config()
|
||||||
|
payload = {
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"username": getpass.getuser(),
|
||||||
|
"os_info": f"{platform.system()} {platform.release()}",
|
||||||
|
"local_ip": get_local_ip(),
|
||||||
|
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
|
||||||
|
"uptime_sec": get_uptime(),
|
||||||
|
"cpu_percent": psutil.cpu_percent(interval=1),
|
||||||
|
"ram_percent": psutil.virtual_memory().percent,
|
||||||
|
"ram_total_gb": round(psutil.virtual_memory().total / (2**30), 1),
|
||||||
|
"disk_info": get_disk_info(),
|
||||||
|
"cpu_temp": get_cpu_temp(),
|
||||||
|
"load_avg": get_load_avg(),
|
||||||
|
"processes": get_top_processes(10),
|
||||||
|
}
|
||||||
|
return payload, cfg
|
||||||
|
|
||||||
|
|
||||||
|
def send(payload, cfg):
|
||||||
|
url = cfg["server_url"].rstrip("/") + "/api/heartbeat"
|
||||||
|
headers = {"Authorization": f"Bearer {cfg['agent_token']}", "Content-Type": "application/json"}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, headers=headers, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logging.info("Heartbeat sent: %s", resp.status_code)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Failed to send heartbeat: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
payload, cfg = collect()
|
||||||
|
send(payload, cfg)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"server_url": "http://127.0.0.1",
|
||||||
|
"agent_token": "agent-secret-token",
|
||||||
|
"interval_minutes": 60,
|
||||||
|
"send_public_ip": false
|
||||||
|
}
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/info-user-agent"
|
||||||
|
SERVICE_NAME="info-user-agent"
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Пожалуйста, запустите от root (sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
cp agent.py requirements.txt config.json "$INSTALL_DIR/"
|
||||||
|
|
||||||
|
python3 -m pip install -r "$INSTALL_DIR/requirements.txt" -q
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/$SERVICE_NAME.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=InfoUser Agent
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/python3 $INSTALL_DIR/agent.py
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/$SERVICE_NAME.timer <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=InfoUser Agent Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*:00/60
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable $SERVICE_NAME.timer
|
||||||
|
systemctl start $SERVICE_NAME.timer
|
||||||
|
|
||||||
|
echo "Агент установлен. Статус таймера:"
|
||||||
|
systemctl status $SERVICE_NAME.timer --no-pager
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
psutil>=6.0
|
||||||
|
requests>=2.32
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: ./server
|
||||||
|
container_name: info-user-app
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
|
- AGENT_TOKEN=${AGENT_TOKEN:-agent-secret-token}
|
||||||
|
- DATABASE_URL=sqlite:///./data/monitor.db
|
||||||
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin}
|
||||||
|
- CREATE_ADMIN_ON_STARTUP=true
|
||||||
|
networks:
|
||||||
|
- info-user-net
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: info-user-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-80}:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
networks:
|
||||||
|
- info-user-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
info-user-net:
|
||||||
|
driver: bridge
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
proxy_pass http://app:8000/static;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from config import get_settings
|
||||||
|
from database import get_db
|
||||||
|
import models
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + (
|
||||||
|
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
)
|
||||||
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
to_encode.update({"exp": expire, "type": "refresh"})
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str, token_type: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
if payload.get("type") != token_type:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
token: Optional[str] = Depends(oauth2_scheme),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Optional[models.User]:
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
payload = verify_token(token, "access")
|
||||||
|
if not payload or "sub" not in payload:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
user = db.query(models.User).filter(models.User.username == payload["sub"]).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: models.User = Depends(get_current_user)):
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_admin(db: Session) -> Optional[models.User]:
|
||||||
|
if not settings.CREATE_ADMIN_ON_STARTUP:
|
||||||
|
return None
|
||||||
|
existing = db.query(models.User).filter(models.User.username == settings.ADMIN_USERNAME).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
admin = models.User(
|
||||||
|
username=settings.ADMIN_USERNAME,
|
||||||
|
password_hash=get_password_hash(settings.ADMIN_PASSWORD),
|
||||||
|
role="admin",
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin)
|
||||||
|
return admin
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
APP_NAME: str = "InfoUser Monitor"
|
||||||
|
SECRET_KEY: str = "change-me-in-production"
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
AGENT_TOKEN: str = "agent-secret-token"
|
||||||
|
DATABASE_URL: str = "sqlite:///./data/monitor.db"
|
||||||
|
ADMIN_USERNAME: str = "admin"
|
||||||
|
ADMIN_PASSWORD: str = "admin"
|
||||||
|
CREATE_ADMIN_ON_STARTUP: bool = True
|
||||||
|
OFFLINE_THRESHOLD_MINUTES: int = 130
|
||||||
|
NOTIFICATION_WEBHOOK_URL: str = ""
|
||||||
|
NOTIFICATION_TELEGRAM_BOT_TOKEN: str = ""
|
||||||
|
NOTIFICATION_TELEGRAM_CHAT_ID: str = ""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_tables():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from database import create_tables, SessionLocal
|
||||||
|
from auth import create_default_admin
|
||||||
|
from routes import auth_router, api_router, web_router
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
create_tables()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
create_default_admin(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="InfoUser Monitor",
|
||||||
|
description="PC monitoring dashboard with FastAPI and agents",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.include_router(web_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, JSON, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String, nullable=False)
|
||||||
|
role = Column(String, default="viewer", nullable=False) # admin | viewer
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Computer(Base):
|
||||||
|
__tablename__ = "computers"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
hostname = Column(String, nullable=False, index=True)
|
||||||
|
os_info = Column(String, nullable=True)
|
||||||
|
first_seen = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
last_seen = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
status = Column(String, default="offline", nullable=False)
|
||||||
|
current_user = Column(String, nullable=True)
|
||||||
|
current_ip = Column(String, nullable=True)
|
||||||
|
current_uptime_sec = Column(Integer, nullable=True)
|
||||||
|
current_cpu_percent = Column(Float, nullable=True)
|
||||||
|
current_ram_percent = Column(Float, nullable=True)
|
||||||
|
current_ram_total_gb = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Heartbeat(Base):
|
||||||
|
__tablename__ = "heartbeats"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
computer_id = Column(Integer, ForeignKey("computers.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
username = Column(String, nullable=True)
|
||||||
|
local_ip = Column(String, nullable=True)
|
||||||
|
public_ip = Column(String, nullable=True)
|
||||||
|
uptime_sec = Column(Integer, nullable=True)
|
||||||
|
cpu_percent = Column(Float, nullable=True)
|
||||||
|
ram_percent = Column(Float, nullable=True)
|
||||||
|
ram_total_gb = Column(Float, nullable=True)
|
||||||
|
disk_info = Column(JSON, nullable=True)
|
||||||
|
cpu_temp = Column(Float, nullable=True)
|
||||||
|
load_avg = Column(String, nullable=True)
|
||||||
|
processes = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
action = Column(String, nullable=False)
|
||||||
|
details = Column(Text, nullable=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
computer_id = Column(Integer, ForeignKey("computers.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
type = Column(String, default="offline", nullable=False) # offline | alert | info
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
is_read = Column(Boolean, default=False, nullable=False)
|
||||||
|
sent_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from config import get_settings
|
||||||
|
from models import Computer, Notification
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_notification(db: Session, computer: Computer, ntype: str, title: str, message: str) -> Notification:
|
||||||
|
n = Notification(
|
||||||
|
computer_id=computer.id,
|
||||||
|
type=ntype,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
db.add(n)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(n)
|
||||||
|
send_external_notifications(n, computer)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def check_offline_computers(db: Session):
|
||||||
|
"""Mark online computers that missed the threshold as offline and create notifications."""
|
||||||
|
threshold = datetime.now(timezone.utc) - timedelta(minutes=settings.OFFLINE_THRESHOLD_MINUTES)
|
||||||
|
stale = (
|
||||||
|
db.query(Computer)
|
||||||
|
.filter(Computer.status == "online", Computer.last_seen < threshold)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for computer in stale:
|
||||||
|
computer.status = "offline"
|
||||||
|
db.add(computer)
|
||||||
|
# avoid duplicate notifications for the same offline event
|
||||||
|
existing = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(
|
||||||
|
Notification.computer_id == computer.id,
|
||||||
|
Notification.type == "offline",
|
||||||
|
Notification.is_read == False,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
msg = (
|
||||||
|
f"ПК {computer.hostname} не присылал данные более "
|
||||||
|
f"{settings.OFFLINE_THRESHOLD_MINUTES} минут. "
|
||||||
|
f"Последний пользователь: {computer.current_user or '-'}, "
|
||||||
|
f"IP: {computer.current_ip or '-'}"
|
||||||
|
)
|
||||||
|
create_notification(db, computer, "offline", f"ПК {computer.hostname} офлайн", msg)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def send_external_notifications(notification: Notification, computer: Computer):
|
||||||
|
if settings.NOTIFICATION_WEBHOOK_URL:
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
settings.NOTIFICATION_WEBHOOK_URL,
|
||||||
|
json={
|
||||||
|
"type": notification.type,
|
||||||
|
"title": notification.title,
|
||||||
|
"message": notification.message,
|
||||||
|
"hostname": computer.hostname,
|
||||||
|
"user": computer.current_user,
|
||||||
|
"ip": computer.current_ip,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Webhook notification failed: %s", e)
|
||||||
|
|
||||||
|
if settings.NOTIFICATION_TELEGRAM_BOT_TOKEN and settings.NOTIFICATION_TELEGRAM_CHAT_ID:
|
||||||
|
try:
|
||||||
|
text = f"*{notification.title}*\n\n{notification.message}"
|
||||||
|
requests.post(
|
||||||
|
f"https://api.telegram.org/bot{settings.NOTIFICATION_TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
|
json={
|
||||||
|
"chat_id": settings.NOTIFICATION_TELEGRAM_CHAT_ID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Telegram notification failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_notification_read(db: Session, notification_id: int) -> bool:
|
||||||
|
n = db.query(Notification).filter(Notification.id == notification_id).first()
|
||||||
|
if not n:
|
||||||
|
return False
|
||||||
|
n.is_read = True
|
||||||
|
n.read_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_unread_count(db: Session) -> int:
|
||||||
|
return db.query(Notification).filter(Notification.is_read == False).count()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
bcrypt==4.0.1
|
||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.0
|
||||||
|
jinja2==3.1.4
|
||||||
|
sqlalchemy==2.0.35
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.12
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
psutil==6.0.0
|
||||||
|
aiosqlite==0.20.0
|
||||||
|
requests==2.32.3
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .auth_routes import router as auth_router
|
||||||
|
from .api_routes import router as api_router
|
||||||
|
from .web_routes import router as web_router
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc, asc, or_
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from config import get_settings
|
||||||
|
from database import get_db
|
||||||
|
from models import Computer, Heartbeat, Notification
|
||||||
|
from schemas import HeartbeatPayload, ComputerOut, ComputerDetailOut, HeartbeatOut, NotificationOut
|
||||||
|
from auth import get_current_user
|
||||||
|
from notifications import check_offline_computers, mark_notification_read
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["api"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_token(request: Request) -> str:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing agent token")
|
||||||
|
return auth[7:]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/heartbeat")
|
||||||
|
def heartbeat(
|
||||||
|
payload: HeartbeatPayload,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
token = get_agent_token(request)
|
||||||
|
if token != settings.AGENT_TOKEN:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid agent token")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
computer = db.query(Computer).filter(Computer.hostname == payload.hostname).first()
|
||||||
|
if not computer:
|
||||||
|
computer = Computer(
|
||||||
|
hostname=payload.hostname,
|
||||||
|
os_info=payload.os_info,
|
||||||
|
first_seen=now,
|
||||||
|
)
|
||||||
|
db.add(computer)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(computer)
|
||||||
|
|
||||||
|
computer.last_seen = now
|
||||||
|
computer.status = "online"
|
||||||
|
computer.current_user = payload.username
|
||||||
|
computer.current_ip = payload.local_ip
|
||||||
|
computer.current_uptime_sec = payload.uptime_sec
|
||||||
|
computer.current_cpu_percent = payload.cpu_percent
|
||||||
|
computer.current_ram_percent = payload.ram_percent
|
||||||
|
computer.current_ram_total_gb = payload.ram_total_gb
|
||||||
|
|
||||||
|
heartbeat = Heartbeat(
|
||||||
|
computer_id=computer.id,
|
||||||
|
timestamp=now,
|
||||||
|
username=payload.username,
|
||||||
|
local_ip=payload.local_ip,
|
||||||
|
public_ip=payload.public_ip,
|
||||||
|
uptime_sec=payload.uptime_sec,
|
||||||
|
cpu_percent=payload.cpu_percent,
|
||||||
|
ram_percent=payload.ram_percent,
|
||||||
|
ram_total_gb=payload.ram_total_gb,
|
||||||
|
disk_info=payload.disk_info or [],
|
||||||
|
cpu_temp=payload.cpu_temp,
|
||||||
|
load_avg=payload.load_avg,
|
||||||
|
processes=payload.processes or [],
|
||||||
|
)
|
||||||
|
db.add(heartbeat)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "computer_id": computer.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/computers", response_model=List[ComputerOut])
|
||||||
|
def list_computers(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
user: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
sort: str = "hostname",
|
||||||
|
order: str = "asc",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Mark old computers as offline and create notifications
|
||||||
|
check_offline_computers(db)
|
||||||
|
|
||||||
|
query = db.query(Computer)
|
||||||
|
|
||||||
|
if status and status in ("online", "offline"):
|
||||||
|
query = query.filter(Computer.status == status)
|
||||||
|
if user:
|
||||||
|
query = query.filter(Computer.current_user.ilike(f"%{user}%"))
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Computer.hostname.ilike(f"%{search}%"),
|
||||||
|
Computer.os_info.ilike(f"%{search}%"),
|
||||||
|
Computer.current_ip.ilike(f"%{search}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_sort = {"hostname", "last_seen", "current_user", "status", "current_cpu_percent", "current_ram_percent"}
|
||||||
|
if sort in allowed_sort:
|
||||||
|
column = getattr(Computer, sort)
|
||||||
|
query = query.order_by(asc(column) if order == "asc" else desc(column))
|
||||||
|
else:
|
||||||
|
query = query.order_by(asc(Computer.hostname))
|
||||||
|
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/computers/{computer_id}", response_model=ComputerDetailOut)
|
||||||
|
def get_computer(
|
||||||
|
computer_id: int,
|
||||||
|
hours: int = 24,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
computer = db.query(Computer).filter(Computer.id == computer_id).first()
|
||||||
|
if not computer:
|
||||||
|
raise HTTPException(status_code=404, detail="Computer not found")
|
||||||
|
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
heartbeats = (
|
||||||
|
db.query(Heartbeat)
|
||||||
|
.filter(Heartbeat.computer_id == computer_id, Heartbeat.timestamp >= since)
|
||||||
|
.order_by(Heartbeat.timestamp)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
computer.heartbeats = heartbeats
|
||||||
|
return computer
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/computers/export/csv")
|
||||||
|
def export_csv(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
user: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
query = db.query(Computer)
|
||||||
|
|
||||||
|
if status and status in ("online", "offline"):
|
||||||
|
query = query.filter(Computer.status == status)
|
||||||
|
if user:
|
||||||
|
query = query.filter(Computer.current_user.ilike(f"%{user}%"))
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Computer.hostname.ilike(f"%{search}%"),
|
||||||
|
Computer.os_info.ilike(f"%{search}%"),
|
||||||
|
Computer.current_ip.ilike(f"%{search}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
computers = query.order_by(Computer.hostname).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow([
|
||||||
|
"ID", "Hostname", "User", "IP", "Status", "OS", "CPU %", "RAM %",
|
||||||
|
"RAM Total GB", "Uptime sec", "Last seen", "First seen",
|
||||||
|
])
|
||||||
|
for c in computers:
|
||||||
|
writer.writerow([
|
||||||
|
c.id,
|
||||||
|
c.hostname,
|
||||||
|
c.current_user or "",
|
||||||
|
c.current_ip or "",
|
||||||
|
c.status,
|
||||||
|
c.os_info or "",
|
||||||
|
c.current_cpu_percent or "",
|
||||||
|
c.current_ram_percent or "",
|
||||||
|
c.current_ram_total_gb or "",
|
||||||
|
c.current_uptime_sec or "",
|
||||||
|
c.last_seen.isoformat() if c.last_seen else "",
|
||||||
|
c.first_seen.isoformat() if c.first_seen else "",
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
filename = f"computers-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.csv"
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(output.getvalue().encode("utf-8-sig")),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications", response_model=List[NotificationOut])
|
||||||
|
def list_notifications(
|
||||||
|
unread_only: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
query = db.query(Notification)
|
||||||
|
if unread_only:
|
||||||
|
query = query.filter(Notification.is_read == False)
|
||||||
|
return query.order_by(desc(Notification.sent_at)).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/{notification_id}/read")
|
||||||
|
def read_notification(
|
||||||
|
notification_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if mark_notification_read(db, notification_id):
|
||||||
|
return {"ok": True}
|
||||||
|
raise HTTPException(status_code=404, detail="Notification not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/read-all")
|
||||||
|
def read_all_notifications(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db.query(Notification).filter(Notification.is_read == False).update(
|
||||||
|
{"is_read": True, "read_at": datetime.now(timezone.utc)}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from auth import (
|
||||||
|
verify_password,
|
||||||
|
get_password_hash,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
verify_token,
|
||||||
|
get_current_user,
|
||||||
|
create_default_admin,
|
||||||
|
)
|
||||||
|
from models import User
|
||||||
|
from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def register(payload: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
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)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
def login(
|
||||||
|
response: Response,
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
create_default_admin(db)
|
||||||
|
user = db.query(User).filter(User.username == form_data.username).first()
|
||||||
|
if not user or not verify_password(form_data.password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
access = create_access_token({"sub": user.username, "role": user.role})
|
||||||
|
refresh = create_refresh_token({"sub": user.username, "role": user.role})
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=access,
|
||||||
|
httponly=True,
|
||||||
|
max_age=1800,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return {"access_token": access, "refresh_token": refresh}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=Token)
|
||||||
|
def refresh(payload: TokenRefresh, response: Response, db: Session = Depends(get_db)):
|
||||||
|
data = verify_token(payload.refresh_token, "refresh")
|
||||||
|
if not data or "sub" not in data:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||||
|
user = db.query(User).filter(User.username == data["sub"]).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
access = create_access_token({"sub": user.username, "role": user.role})
|
||||||
|
refresh = create_refresh_token({"sub": user.username, "role": user.role})
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=access,
|
||||||
|
httponly=True,
|
||||||
|
max_age=1800,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return {"access_token": access, "refresh_token": refresh}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
def me(user: User = Depends(get_current_user)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(response: Response):
|
||||||
|
response.delete_cookie("access_token")
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from auth import verify_token
|
||||||
|
from models import Computer, Heartbeat, User, Notification
|
||||||
|
from notifications import check_offline_computers, get_unread_count
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def user_or_redirect(request: Request, db: Session):
|
||||||
|
token = request.cookies.get("access_token")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
payload = verify_token(token, "access")
|
||||||
|
if not payload or "sub" not in payload:
|
||||||
|
return None
|
||||||
|
return db.query(User).filter(User.username == payload["sub"]).first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
def index(
|
||||||
|
request: Request,
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
user: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
sort: str = Query("hostname"),
|
||||||
|
order: str = Query("asc"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
current_user = user_or_redirect(request, db)
|
||||||
|
if not current_user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
check_offline_computers(db)
|
||||||
|
|
||||||
|
query = db.query(Computer)
|
||||||
|
if status and status in ("online", "offline"):
|
||||||
|
query = query.filter(Computer.status == status)
|
||||||
|
if user:
|
||||||
|
query = query.filter(Computer.current_user.ilike(f"%{user}%"))
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
Computer.hostname.ilike(f"%{search}%")
|
||||||
|
| Computer.os_info.ilike(f"%{search}%")
|
||||||
|
| Computer.current_ip.ilike(f"%{search}%")
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_sort = {"hostname", "last_seen", "current_user", "status", "current_cpu_percent", "current_ram_percent"}
|
||||||
|
if sort in allowed_sort:
|
||||||
|
column = getattr(Computer, sort)
|
||||||
|
if order == "desc":
|
||||||
|
query = query.order_by(desc(column))
|
||||||
|
else:
|
||||||
|
query = query.order_by(column)
|
||||||
|
else:
|
||||||
|
query = query.order_by(Computer.hostname)
|
||||||
|
|
||||||
|
computers = query.all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dashboard.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": current_user,
|
||||||
|
"computers": computers,
|
||||||
|
"filters": {
|
||||||
|
"status": status or "",
|
||||||
|
"user": user or "",
|
||||||
|
"search": search or "",
|
||||||
|
"sort": sort,
|
||||||
|
"order": order,
|
||||||
|
},
|
||||||
|
"unread_count": get_unread_count(db),
|
||||||
|
"now": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
def login_page(request: Request):
|
||||||
|
return templates.TemplateResponse("login.html", {"request": request, "error": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/computers/{computer_id}", response_class=HTMLResponse)
|
||||||
|
def computer_page(computer_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
|
current_user = user_or_redirect(request, db)
|
||||||
|
if not current_user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
computer = db.query(Computer).filter(Computer.id == computer_id).first()
|
||||||
|
if not computer:
|
||||||
|
raise HTTPException(status_code=404, detail="Computer not found")
|
||||||
|
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
heartbeats = (
|
||||||
|
db.query(Heartbeat)
|
||||||
|
.filter(Heartbeat.computer_id == computer_id, Heartbeat.timestamp >= since)
|
||||||
|
.order_by(Heartbeat.timestamp)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
hb_data = []
|
||||||
|
for h in heartbeats:
|
||||||
|
hb_data.append({
|
||||||
|
"timestamp": h.timestamp.isoformat() if h.timestamp else None,
|
||||||
|
"username": h.username,
|
||||||
|
"local_ip": h.local_ip,
|
||||||
|
"cpu_percent": h.cpu_percent,
|
||||||
|
"ram_percent": h.ram_percent,
|
||||||
|
"cpu_temp": h.cpu_temp,
|
||||||
|
"processes": h.processes or [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"computer.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": current_user,
|
||||||
|
"computer": computer,
|
||||||
|
"heartbeats": hb_data,
|
||||||
|
"unread_count": get_unread_count(db),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications", response_class=HTMLResponse)
|
||||||
|
def notifications_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
current_user = user_or_redirect(request, db)
|
||||||
|
if not current_user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
notifications = (
|
||||||
|
db.query(Notification)
|
||||||
|
.order_by(desc(Notification.sent_at))
|
||||||
|
.limit(100)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"notifications.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": current_user,
|
||||||
|
"notifications": notifications,
|
||||||
|
"unread_count": get_unread_count(db),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
created_at: Optional[datetime]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefresh(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginPayload(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatPayload(BaseModel):
|
||||||
|
hostname: str
|
||||||
|
username: str
|
||||||
|
os_info: Optional[str] = None
|
||||||
|
local_ip: Optional[str] = None
|
||||||
|
public_ip: Optional[str] = None
|
||||||
|
uptime_sec: int
|
||||||
|
cpu_percent: float
|
||||||
|
ram_percent: float
|
||||||
|
ram_total_gb: Optional[float] = None
|
||||||
|
disk_info: Optional[List[dict]] = None
|
||||||
|
cpu_temp: Optional[float] = None
|
||||||
|
load_avg: Optional[str] = None
|
||||||
|
processes: Optional[List[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
timestamp: datetime
|
||||||
|
username: Optional[str]
|
||||||
|
local_ip: Optional[str]
|
||||||
|
cpu_percent: Optional[float]
|
||||||
|
ram_percent: Optional[float]
|
||||||
|
cpu_temp: Optional[float]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ComputerOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
hostname: str
|
||||||
|
os_info: Optional[str]
|
||||||
|
first_seen: Optional[datetime]
|
||||||
|
last_seen: Optional[datetime]
|
||||||
|
status: str
|
||||||
|
current_user: Optional[str]
|
||||||
|
current_ip: Optional[str]
|
||||||
|
current_cpu_percent: Optional[float]
|
||||||
|
current_ram_percent: Optional[float]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ComputerDetailOut(ComputerOut):
|
||||||
|
heartbeats: List[HeartbeatOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
computer_id: Optional[int]
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
is_read: bool
|
||||||
|
sent_at: Optional[datetime]
|
||||||
|
read_at: Optional[datetime]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
function escapeHtml(text) {
|
||||||
|
if (text == null) return '';
|
||||||
|
return text.toString()
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const logout = document.getElementById('logout-link');
|
||||||
|
if (logout) {
|
||||||
|
logout.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
body {
|
||||||
|
background-color: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-seen {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#computers-table tbody tr {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#computers-table tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}InfoUser Monitor{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/">InfoUser Monitor</a>
|
||||||
|
{% if user %}
|
||||||
|
<div class="collapse navbar-collapse">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-center">
|
||||||
|
<li class="nav-item me-3">
|
||||||
|
<a href="/notifications" class="nav-link position-relative">
|
||||||
|
<i class="bi bi-bell"></i>
|
||||||
|
{% if unread_count %}
|
||||||
|
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
|
||||||
|
{{ unread_count }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item"><span class="nav-link">{{ user.username }}</span></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/" id="logout-link">Выйти</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container py-4">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ computer.hostname }} — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>{{ computer.hostname }}</h2>
|
||||||
|
<a href="/" class="btn btn-outline-secondary">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">Статус</div>
|
||||||
|
<span class="badge {% if computer.status == 'online' %}bg-success{% else %}bg-secondary{% endif %} fs-6">
|
||||||
|
{{ computer.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">Пользователь</div>
|
||||||
|
<div class="fw-bold">{{ computer.current_user or '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">IP</div>
|
||||||
|
<div class="fw-bold">{{ computer.current_ip or '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">OS</div>
|
||||||
|
<div class="fw-bold">{{ computer.os_info or '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">CPU, %</h5>
|
||||||
|
<canvas id="cpuChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">RAM, %</h5>
|
||||||
|
<canvas id="ramChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Последние процессы</h5>
|
||||||
|
{% if heartbeats and heartbeats[-1].processes %}
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>PID</th><th>Имя</th><th>CPU%</th><th>RAM%</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in heartbeats[-1].processes[:10] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.pid }}</td>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td>{{ p.cpu_percent | round(1) }}</td>
|
||||||
|
<td>{{ p.ram_percent | round(1) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">Нет данных о процессах.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">История heartbeats</h5>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>Время</th><th>CPU</th><th>RAM</th><th>IP</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for h in heartbeats[-20:] | reverse %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ h.timestamp[:16] | replace('T', ' ') if h.timestamp else '-' }}</td>
|
||||||
|
<td>{{ h.cpu_percent | round(1) }}%</td>
|
||||||
|
<td>{{ h.ram_percent | round(1) }}%</td>
|
||||||
|
<td>{{ h.local_ip or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const heartbeats = {{ heartbeats | tojson }};
|
||||||
|
const labels = heartbeats.map(h => h.timestamp.replace('T', ' ').slice(0, 16));
|
||||||
|
const cpuData = heartbeats.map(h => h.cpu_percent);
|
||||||
|
const ramData = heartbeats.map(h => h.ram_percent);
|
||||||
|
|
||||||
|
new Chart(document.getElementById('cpuChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels, datasets: [{ label: 'CPU %', data: cpuData, borderColor: 'rgb(255, 99, 132)', tension: 0.2 }] },
|
||||||
|
options: { scales: { y: { beginAtZero: true, max: 100 } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('ramChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels, datasets: [{ label: 'RAM %', data: ramData, borderColor: 'rgb(54, 162, 235)', tension: 0.2 }] },
|
||||||
|
options: { scales: { y: { beginAtZero: true, max: 100 } } }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Дашборд — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Компьютеры</h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/api/computers/export/csv?{{ request.query_params }}" class="btn btn-outline-success btn-sm" target="_blank">
|
||||||
|
<i class="bi bi-download"></i> Экспорт CSV
|
||||||
|
</a>
|
||||||
|
<a href="/notifications" class="btn btn-outline-info btn-sm">
|
||||||
|
<i class="bi bi-bell"></i> Уведомления {% if unread_count %}<span class="badge bg-danger">{{ unread_count }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="filter-form" class="row g-2 mb-4 p-3 bg-white rounded shadow-sm" method="get" action="/">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="online" {% if filters.status == 'online' %}selected{% endif %}>Online</option>
|
||||||
|
<option value="offline" {% if filters.status == 'offline' %}selected{% endif %}>Offline</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="text" name="user" class="form-control form-control-sm" placeholder="Пользователь" value="{{ filters.user }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" name="search" class="form-control form-control-sm" placeholder="Поиск по хосту/IP/OS" value="{{ filters.search }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="sort" class="form-select form-select-sm">
|
||||||
|
<option value="hostname" {% if filters.sort == 'hostname' %}selected{% endif %}>Сортировка: хост</option>
|
||||||
|
<option value="last_seen" {% if filters.sort == 'last_seen' %}selected{% endif %}>Последний чек</option>
|
||||||
|
<option value="current_user" {% if filters.sort == 'current_user' %}selected{% endif %}>Пользователь</option>
|
||||||
|
<option value="status" {% if filters.sort == 'status' %}selected{% endif %}>Статус</option>
|
||||||
|
<option value="current_cpu_percent" {% if filters.sort == 'current_cpu_percent' %}selected{% endif %}>CPU</option>
|
||||||
|
<option value="current_ram_percent" {% if filters.sort == 'current_ram_percent' %}selected{% endif %}>RAM</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<select name="order" class="form-select form-select-sm">
|
||||||
|
<option value="asc" {% if filters.order == 'asc' %}selected{% endif %}>Asc</option>
|
||||||
|
<option value="desc" {% if filters.order == 'desc' %}selected{% endif %}>Desc</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">Применить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table class="table table-hover align-middle" id="computers-table">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Хост</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>CPU</th>
|
||||||
|
<th>RAM</th>
|
||||||
|
<th>OS</th>
|
||||||
|
<th>Последний чек</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in computers %}
|
||||||
|
<tr data-id="{{ c.id }}">
|
||||||
|
<td><strong>{{ c.hostname }}</strong></td>
|
||||||
|
<td>{{ c.current_user or '-' }}</td>
|
||||||
|
<td>{{ c.current_ip or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if c.status == 'online' %}
|
||||||
|
<span class="badge bg-success">Online</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Offline</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ c.current_cpu_percent | round(1) if c.current_cpu_percent is not none else '-' }}%</td>
|
||||||
|
<td>{{ c.current_ram_percent | round(1) if c.current_ram_percent is not none else '-' }}%</td>
|
||||||
|
<td>{{ c.os_info or '-' }}</td>
|
||||||
|
<td class="last-seen">{% if c.last_seen %}{{ c.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}-{% endif %}</td>
|
||||||
|
<td><a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if not computers %}
|
||||||
|
<div class="alert alert-info">Пока нет данных. Установите и запустите агент на клиентских ПК.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function currentQuery() {
|
||||||
|
return new URLSearchParams(window.location.search).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/computers?' + currentQuery());
|
||||||
|
if (!res.ok) return location.reload();
|
||||||
|
const data = await res.json();
|
||||||
|
const tbody = document.querySelector('#computers-table tbody');
|
||||||
|
tbody.innerHTML = data.map(c => `
|
||||||
|
<tr data-id="${c.id}">
|
||||||
|
<td><strong>${escapeHtml(c.hostname)}</strong></td>
|
||||||
|
<td>${escapeHtml(c.current_user || '-')}</td>
|
||||||
|
<td>${escapeHtml(c.current_ip || '-')}</td>
|
||||||
|
<td>${c.status === 'online' ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>'}</td>
|
||||||
|
<td>${c.current_cpu_percent != null ? c.current_cpu_percent.toFixed(1) : '-'}%</td>
|
||||||
|
<td>${c.current_ram_percent != null ? c.current_ram_percent.toFixed(1) : '-'}%</td>
|
||||||
|
<td>${escapeHtml(c.os_info || '-')}</td>
|
||||||
|
<td class="last-seen">${c.last_seen ? c.last_seen.replace('T', ' ').slice(0, 16) : '-'}</td>
|
||||||
|
<td><a href="/computers/${c.id}" class="btn btn-sm btn-outline-primary">Детали</a></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Dashboard refresh failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInterval(loadDashboard, 60000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Вход — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title text-center mb-4">InfoUser Monitor</h4>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Логин</label>
|
||||||
|
<input type="text" class="form-control" id="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Пароль</label>
|
||||||
|
<input type="password" class="form-control" id="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Войти</button>
|
||||||
|
</form>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger mt-3">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div id="login-error" class="alert alert-danger mt-3 d-none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-3 text-muted small">
|
||||||
|
По умолчанию: admin / admin
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const err = document.getElementById('login-error');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('username', document.getElementById('username').value);
|
||||||
|
form.append('password', document.getElementById('password').value);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', { method: 'POST', body: form });
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
err.textContent = data.detail || 'Ошибка входа';
|
||||||
|
err.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = 'Сервер недоступен';
|
||||||
|
err.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Уведомления — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Уведомления</h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||||
|
<button id="mark-all-read" class="btn btn-outline-primary btn-sm">Прочитать все</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notifications-list">
|
||||||
|
{% if notifications %}
|
||||||
|
{% for n in notifications %}
|
||||||
|
<div class="card mb-2 notification-card {% if not n.is_read %}border-start border-danger border-4{% endif %}" data-id="{{ n.id }}">
|
||||||
|
<div class="card-body d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<h6 class="card-title mb-1">{{ n.title }}</h6>
|
||||||
|
<p class="card-text mb-1 text-muted small">{{ n.message }}</p>
|
||||||
|
<p class="card-text"><small class="text-muted">{{ n.sent_at.strftime('%Y-%m-%d %H:%M') if n.sent_at else '-' }}</small></p>
|
||||||
|
</div>
|
||||||
|
{% if not n.is_read %}
|
||||||
|
<button class="btn btn-sm btn-outline-success mark-read" data-id="{{ n.id }}">Прочитать</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">Уведомлений пока нет.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function markRead(id) {
|
||||||
|
const res = await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.mark-read').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => markRead(btn.dataset.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mark-all-read')?.addEventListener('click', async () => {
|
||||||
|
const res = await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user