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
+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),
},
)