Initial commit: InfoUser Monitor with FastAPI, agent, Docker, JWT auth, notifications, filters and CSV export
This commit is contained in:
@@ -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