commit cf2727da2b2d50fa500994fd7010e7aa671a7749 Author: Hidosi Date: Thu Jul 16 11:53:51 2026 +0700 Initial commit: InfoUser Monitor with FastAPI, agent, Docker, JWT auth, notifications, filters and CSV export diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a9fce5a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +*.sqlite +*.sqlite3 +.DS_Store +.vscode/ +.idea/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7c82df4 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca169d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +data/ +.env +__pycache__/ +*.pyc +.DS_Store +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f64fc8 --- /dev/null +++ b/README.md @@ -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 < /etc/systemd/system/$SERVICE_NAME.service < /etc/systemd/system/$SERVICE_NAME.timer <=6.0 +requests>=2.32 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4160d3 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..6992177 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..d4fbfd9 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..d237baf --- /dev/null +++ b/server/auth.py @@ -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 diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..d72fc84 --- /dev/null +++ b/server/config.py @@ -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() diff --git a/server/database.py b/server/database.py new file mode 100644 index 0000000..8b42bcd --- /dev/null +++ b/server/database.py @@ -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) diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..ee61bf5 --- /dev/null +++ b/server/main.py @@ -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"} diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..7e077bc --- /dev/null +++ b/server/models.py @@ -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) + diff --git a/server/notifications.py b/server/notifications.py new file mode 100644 index 0000000..544a9e9 --- /dev/null +++ b/server/notifications.py @@ -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() diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..bcd2483 --- /dev/null +++ b/server/requirements.txt @@ -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 diff --git a/server/routes/__init__.py b/server/routes/__init__.py new file mode 100644 index 0000000..a3e02db --- /dev/null +++ b/server/routes/__init__.py @@ -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 diff --git a/server/routes/api_routes.py b/server/routes/api_routes.py new file mode 100644 index 0000000..9abc9cf --- /dev/null +++ b/server/routes/api_routes.py @@ -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} diff --git a/server/routes/auth_routes.py b/server/routes/auth_routes.py new file mode 100644 index 0000000..bbd727e --- /dev/null +++ b/server/routes/auth_routes.py @@ -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} diff --git a/server/routes/web_routes.py b/server/routes/web_routes.py new file mode 100644 index 0000000..6e985ea --- /dev/null +++ b/server/routes/web_routes.py @@ -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), + }, + ) diff --git a/server/schemas.py b/server/schemas.py new file mode 100644 index 0000000..42d7121 --- /dev/null +++ b/server/schemas.py @@ -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) diff --git a/server/static/app.js b/server/static/app.js new file mode 100644 index 0000000..d0cc991 --- /dev/null +++ b/server/static/app.js @@ -0,0 +1,19 @@ +function escapeHtml(text) { + if (text == null) return ''; + return text.toString() + .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'; + }); + } +}); diff --git a/server/static/style.css b/server/static/style.css new file mode 100644 index 0000000..2e26b34 --- /dev/null +++ b/server/static/style.css @@ -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; +} diff --git a/server/templates/base.html b/server/templates/base.html new file mode 100644 index 0000000..5d17205 --- /dev/null +++ b/server/templates/base.html @@ -0,0 +1,44 @@ + + + + + + {% block title %}InfoUser Monitor{% endblock %} + + + + {% block head %}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + + +{% block scripts %}{% endblock %} + + diff --git a/server/templates/computer.html b/server/templates/computer.html new file mode 100644 index 0000000..e8d1e9f --- /dev/null +++ b/server/templates/computer.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block title %}{{ computer.hostname }} — InfoUser Monitor{% endblock %} + +{% block content %} +
+

{{ computer.hostname }}

+ ← Назад +
+ +
+
+
+
+
Статус
+ + {{ computer.status }} + +
+
+
+
+
+
+
Пользователь
+
{{ computer.current_user or '-' }}
+
+
+
+
+
+
+
IP
+
{{ computer.current_ip or '-' }}
+
+
+
+
+
+
+
OS
+
{{ computer.os_info or '-' }}
+
+
+
+
+ +
+
+
+
+
CPU, %
+ +
+
+
+
+
+
+
RAM, %
+ +
+
+
+
+ +
+
+
Последние процессы
+ {% if heartbeats and heartbeats[-1].processes %} + + + + {% for p in heartbeats[-1].processes[:10] %} + + + + + + + {% endfor %} + +
PIDИмяCPU%RAM%
{{ p.pid }}{{ p.name }}{{ p.cpu_percent | round(1) }}{{ p.ram_percent | round(1) }}
+ {% else %} +
Нет данных о процессах.
+ {% endif %} +
+
+ +
+
+
История heartbeats
+ + + + {% for h in heartbeats[-20:] | reverse %} + + + + + + + {% endfor %} + +
ВремяCPURAMIP
{{ h.timestamp[:16] | replace('T', ' ') if h.timestamp else '-' }}{{ h.cpu_percent | round(1) }}%{{ h.ram_percent | round(1) }}%{{ h.local_ip or '-' }}
+
+
+ + +{% endblock %} diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html new file mode 100644 index 0000000..b42b9aa --- /dev/null +++ b/server/templates/dashboard.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} + +{% block title %}Дашборд — InfoUser Monitor{% endblock %} + +{% block content %} + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + {% for c in computers %} + + + + + + + + + + + + {% endfor %} + +
ХостПользовательIPСтатусCPURAMOSПоследний чек
{{ c.hostname }}{{ c.current_user or '-' }}{{ c.current_ip or '-' }} + {% if c.status == 'online' %} + Online + {% else %} + Offline + {% endif %} + {{ c.current_cpu_percent | round(1) if c.current_cpu_percent is not none else '-' }}%{{ c.current_ram_percent | round(1) if c.current_ram_percent is not none else '-' }}%{{ c.os_info or '-' }}{% if c.last_seen %}{{ c.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}-{% endif %}Детали
+ +{% if not computers %} +
Пока нет данных. Установите и запустите агент на клиентских ПК.
+{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/server/templates/login.html b/server/templates/login.html new file mode 100644 index 0000000..fe62d6c --- /dev/null +++ b/server/templates/login.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}Вход — InfoUser Monitor{% endblock %} + +{% block content %} +
+
+
+
+

InfoUser Monitor

+
+
+ + +
+
+ + +
+ +
+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+
+
+ По умолчанию: admin / admin +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/server/templates/notifications.html b/server/templates/notifications.html new file mode 100644 index 0000000..667ccfd --- /dev/null +++ b/server/templates/notifications.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} + +{% block title %}Уведомления — InfoUser Monitor{% endblock %} + +{% block content %} +
+

Уведомления

+
+ ← Назад + +
+
+ +
+ {% if notifications %} + {% for n in notifications %} +
+
+
+
{{ n.title }}
+

{{ n.message }}

+

{{ n.sent_at.strftime('%Y-%m-%d %H:%M') if n.sent_at else '-' }}

+
+ {% if not n.is_read %} + + {% endif %} +
+
+ {% endfor %} + {% else %} +
Уведомлений пока нет.
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %}