Initial commit: InfoUser Monitor with FastAPI, agent, Docker, JWT auth, notifications, filters and CSV export

This commit is contained in:
2026-07-16 11:53:51 +07:00
commit cf2727da2b
31 changed files with 1957 additions and 0 deletions
+17
View File
@@ -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"]
+91
View File
@@ -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
+27
View File
@@ -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()
+26
View File
@@ -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)
+46
View File
@@ -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"}
+73
View File
@@ -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)
+105
View File
@@ -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()
+14
View File
@@ -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
+3
View File
@@ -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
+232
View File
@@ -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}
+89
View File
@@ -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}
+155
View File
@@ -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),
},
)
+92
View File
@@ -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)
+19
View File
@@ -0,0 +1,19 @@
function escapeHtml(text) {
if (text == null) return '';
return text.toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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';
});
}
});
+36
View File
@@ -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;
}
+44
View File
@@ -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>
+127
View File
@@ -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 %}
+126
View File
@@ -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 %}
+59
View File
@@ -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 %}
+52
View File
@@ -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 %}