diff --git a/server/auth.py b/server/auth.py
index d237baf..a56ebd9 100644
--- a/server/auth.py
+++ b/server/auth.py
@@ -74,6 +74,20 @@ def require_admin(user: models.User = Depends(get_current_user)):
return user
+def log_action(db: Session, user: Optional[models.User], action: str, details: Optional[str] = None):
+ try:
+ entry = models.AuditLog(
+ user_id=user.id if user else None,
+ username=user.username if user else None,
+ action=action,
+ details=details,
+ )
+ db.add(entry)
+ db.commit()
+ except Exception:
+ db.rollback()
+
+
def create_default_admin(db: Session) -> Optional[models.User]:
if not settings.CREATE_ADMIN_ON_STARTUP:
return None
diff --git a/server/database.py b/server/database.py
index 8b42bcd..8961bed 100644
--- a/server/database.py
+++ b/server/database.py
@@ -24,3 +24,17 @@ def get_db():
def create_tables():
Base.metadata.create_all(bind=engine)
+
+
+def run_migrations():
+ """Apply lightweight schema migrations for SQLite."""
+ from sqlalchemy import inspect, text
+ insp = inspect(engine)
+
+ # AuditLog: add username column if missing
+ if "audit_log" in insp.get_table_names():
+ audit_cols = {c["name"] for c in insp.get_columns("audit_log")}
+ if "username" not in audit_cols:
+ with engine.connect() as conn:
+ conn.execute(text("ALTER TABLE audit_log ADD COLUMN username TEXT"))
+ conn.commit()
diff --git a/server/main.py b/server/main.py
index ee61bf5..74050ac 100644
--- a/server/main.py
+++ b/server/main.py
@@ -3,7 +3,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
-from database import create_tables, SessionLocal
+from database import create_tables, run_migrations, SessionLocal
from auth import create_default_admin
from routes import auth_router, api_router, web_router
@@ -11,6 +11,7 @@ from routes import auth_router, api_router, web_router
@asynccontextmanager
async def lifespan(app: FastAPI):
create_tables()
+ run_migrations()
db = SessionLocal()
try:
create_default_admin(db)
diff --git a/server/models.py b/server/models.py
index 7e077bc..8fa2305 100644
--- a/server/models.py
+++ b/server/models.py
@@ -54,6 +54,7 @@ class AuditLog(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ username = Column(String, nullable=True)
action = Column(String, nullable=False)
details = Column(Text, nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
@@ -71,3 +72,12 @@ class Notification(Base):
sent_at = Column(DateTime(timezone=True), server_default=func.now())
read_at = Column(DateTime(timezone=True), nullable=True)
+
+class Setting(Base):
+ __tablename__ = "settings"
+
+ id = Column(Integer, primary_key=True, index=True)
+ key = Column(String, unique=True, nullable=False, index=True)
+ value = Column(Text, nullable=True)
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
diff --git a/server/notifications.py b/server/notifications.py
index 544a9e9..b5c126d 100644
--- a/server/notifications.py
+++ b/server/notifications.py
@@ -6,11 +6,19 @@ from sqlalchemy.orm import Session
from config import get_settings
from models import Computer, Notification
+from settings_store import get_setting
settings = get_settings()
logger = logging.getLogger(__name__)
+def _int_or_default(value, default):
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return default
+
+
def create_notification(db: Session, computer: Computer, ntype: str, title: str, message: str) -> Notification:
n = Notification(
computer_id=computer.id,
@@ -21,13 +29,17 @@ def create_notification(db: Session, computer: Computer, ntype: str, title: str,
db.add(n)
db.commit()
db.refresh(n)
- send_external_notifications(n, computer)
+ send_external_notifications(db, 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)
+ threshold_minutes = _int_or_default(
+ get_setting(db, "OFFLINE_THRESHOLD_MINUTES"),
+ settings.OFFLINE_THRESHOLD_MINUTES,
+ )
+ threshold = datetime.now(timezone.utc) - timedelta(minutes=threshold_minutes)
stale = (
db.query(Computer)
.filter(Computer.status == "online", Computer.last_seen < threshold)
@@ -49,7 +61,7 @@ def check_offline_computers(db: Session):
if not existing:
msg = (
f"ПК {computer.hostname} не присылал данные более "
- f"{settings.OFFLINE_THRESHOLD_MINUTES} минут. "
+ f"{threshold_minutes} минут. "
f"Последний пользователь: {computer.current_user or '-'}, "
f"IP: {computer.current_ip or '-'}"
)
@@ -57,11 +69,15 @@ def check_offline_computers(db: Session):
db.commit()
-def send_external_notifications(notification: Notification, computer: Computer):
- if settings.NOTIFICATION_WEBHOOK_URL:
+def send_external_notifications(db: Session, notification: Notification, computer: Computer):
+ webhook_url = get_setting(db, "NOTIFICATION_WEBHOOK_URL") or settings.NOTIFICATION_WEBHOOK_URL
+ telegram_token = get_setting(db, "NOTIFICATION_TELEGRAM_BOT_TOKEN") or settings.NOTIFICATION_TELEGRAM_BOT_TOKEN
+ telegram_chat = get_setting(db, "NOTIFICATION_TELEGRAM_CHAT_ID") or settings.NOTIFICATION_TELEGRAM_CHAT_ID
+
+ if webhook_url:
try:
requests.post(
- settings.NOTIFICATION_WEBHOOK_URL,
+ webhook_url,
json={
"type": notification.type,
"title": notification.title,
@@ -75,13 +91,13 @@ def send_external_notifications(notification: Notification, computer: Computer):
except Exception as e:
logger.error("Webhook notification failed: %s", e)
- if settings.NOTIFICATION_TELEGRAM_BOT_TOKEN and settings.NOTIFICATION_TELEGRAM_CHAT_ID:
+ if telegram_token and telegram_chat:
try:
text = f"*{notification.title}*\n\n{notification.message}"
requests.post(
- f"https://api.telegram.org/bot{settings.NOTIFICATION_TELEGRAM_BOT_TOKEN}/sendMessage",
+ f"https://api.telegram.org/bot{telegram_token}/sendMessage",
json={
- "chat_id": settings.NOTIFICATION_TELEGRAM_CHAT_ID,
+ "chat_id": telegram_chat,
"text": text,
"parse_mode": "Markdown",
},
diff --git a/server/routes/api_routes.py b/server/routes/api_routes.py
index 9abc9cf..98a655f 100644
--- a/server/routes/api_routes.py
+++ b/server/routes/api_routes.py
@@ -10,10 +10,20 @@ 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 models import Computer, Heartbeat, Notification, AuditLog
+from schemas import (
+ HeartbeatPayload,
+ ComputerOut,
+ ComputerDetailOut,
+ HeartbeatOut,
+ NotificationOut,
+ AuditLogOut,
+ SettingOut,
+ SettingsUpdate,
+)
+from auth import get_current_user, require_admin, log_action
from notifications import check_offline_computers, mark_notification_read
+from settings_store import get_setting, set_setting, get_all_settings
router = APIRouter(prefix="/api", tags=["api"])
settings = get_settings()
@@ -33,7 +43,8 @@ def heartbeat(
db: Session = Depends(get_db),
):
token = get_agent_token(request)
- if token != settings.AGENT_TOKEN:
+ expected = get_setting(db, "AGENT_TOKEN") or settings.AGENT_TOKEN
+ if token != expected:
raise HTTPException(status_code=401, detail="Invalid agent token")
now = datetime.now(timezone.utc)
@@ -140,6 +151,23 @@ def get_computer(
return computer
+@router.delete("/computers/{computer_id}")
+def delete_computer(
+ computer_id: int,
+ db: Session = Depends(get_db),
+ admin=Depends(require_admin),
+):
+ computer = db.query(Computer).filter(Computer.id == computer_id).first()
+ if not computer:
+ raise HTTPException(status_code=404, detail="Computer not found")
+
+ hostname = computer.hostname
+ db.delete(computer)
+ db.commit()
+ log_action(db, admin, "computer_deleted", f"Deleted computer {hostname} (id={computer_id})")
+ return {"ok": True}
+
+
@router.get("/computers/export/csv")
def export_csv(
status: Optional[str] = None,
@@ -230,3 +258,40 @@ def read_all_notifications(
)
db.commit()
return {"ok": True}
+
+
+# Admin: audit log
+
+@router.get("/audit", response_model=List[AuditLogOut])
+def list_audit(
+ limit: int = 200,
+ db: Session = Depends(get_db),
+ admin=Depends(require_admin),
+):
+ return db.query(AuditLog).order_by(desc(AuditLog.timestamp)).limit(limit).all()
+
+
+# Admin: settings
+
+@router.get("/settings")
+def list_settings(
+ db: Session = Depends(get_db),
+ admin=Depends(require_admin),
+):
+ return get_all_settings(db)
+
+
+@router.post("/settings")
+def update_settings(
+ payload: SettingsUpdate,
+ db: Session = Depends(get_db),
+ admin=Depends(require_admin),
+):
+ changed = []
+ data = payload.model_dump(exclude_unset=True)
+ for key, value in data.items():
+ set_setting(db, key, value)
+ changed.append(key)
+ if changed:
+ log_action(db, admin, "settings_updated", f"Updated: {', '.join(changed)}")
+ return get_all_settings(db)
diff --git a/server/routes/auth_routes.py b/server/routes/auth_routes.py
index bbd727e..fb8e3af 100644
--- a/server/routes/auth_routes.py
+++ b/server/routes/auth_routes.py
@@ -1,8 +1,7 @@
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 typing import Optional, List
from database import get_db
from auth import (
@@ -12,10 +11,12 @@ from auth import (
create_refresh_token,
verify_token,
get_current_user,
+ require_admin,
create_default_admin,
+ log_action,
)
from models import User
-from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh
+from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh, UserUpdate, UserListOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -87,3 +88,87 @@ def me(user: User = Depends(get_current_user)):
def logout(response: Response):
response.delete_cookie("access_token")
return {"ok": True}
+
+
+# Admin user management
+
+@router.get("/users", response_model=List[UserListOut])
+def list_users(
+ db: Session = Depends(get_db),
+ admin: User = Depends(require_admin),
+):
+ return db.query(User).order_by(User.username).all()
+
+
+@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
+def create_user(
+ payload: UserCreate,
+ db: Session = Depends(get_db),
+ admin: User = Depends(require_admin),
+):
+ 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)
+ log_action(db, admin, "user_created", f"Created user {user.username} (id={user.id})")
+ return user
+
+
+@router.patch("/users/{user_id}", response_model=UserOut)
+def update_user(
+ user_id: int,
+ payload: UserUpdate,
+ db: Session = Depends(get_db),
+ admin: User = Depends(require_admin),
+):
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ if user.id == admin.id and payload.role and payload.role != "admin":
+ raise HTTPException(status_code=400, detail="Cannot downgrade yourself")
+
+ if payload.role:
+ if payload.role not in ("admin", "viewer"):
+ raise HTTPException(status_code=400, detail="Invalid role")
+ user.role = payload.role
+
+ if payload.password:
+ user.password_hash = get_password_hash(payload.password)
+
+ db.commit()
+ db.refresh(user)
+ log_action(db, admin, "user_updated", f"Updated user {user.username} (id={user.id}), role={user.role}, password_changed={bool(payload.password)}")
+ return user
+
+
+@router.delete("/users/{user_id}")
+def delete_user(
+ user_id: int,
+ db: Session = Depends(get_db),
+ admin: User = Depends(require_admin),
+):
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if user.id == admin.id:
+ raise HTTPException(status_code=400, detail="Cannot delete yourself")
+
+ # prevent deleting last admin
+ if user.role == "admin":
+ admin_count = db.query(User).filter(User.role == "admin").count()
+ if admin_count <= 1:
+ raise HTTPException(status_code=400, detail="Cannot delete the last admin")
+
+ username = user.username
+ db.delete(user)
+ db.commit()
+ log_action(db, admin, "user_deleted", f"Deleted user {username} (id={user_id})")
+ return {"ok": True}
diff --git a/server/routes/web_routes.py b/server/routes/web_routes.py
index 6e985ea..ed2ca6c 100644
--- a/server/routes/web_routes.py
+++ b/server/routes/web_routes.py
@@ -7,9 +7,10 @@ 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 auth import verify_token, require_admin
+from models import Computer, Heartbeat, User, Notification, AuditLog, Setting
from notifications import check_offline_computers, get_unread_count
+from settings_store import get_all_settings
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@@ -25,6 +26,10 @@ def user_or_redirect(request: Request, db: Session):
return db.query(User).filter(User.username == payload["sub"]).first()
+def has_admin(db: Session) -> bool:
+ return db.query(User).filter(User.role == "admin").count() > 0
+
+
@router.get("/", response_class=HTMLResponse)
def index(
request: Request,
@@ -85,8 +90,10 @@ def index(
@router.get("/login", response_class=HTMLResponse)
-def login_page(request: Request):
- return templates.TemplateResponse("login.html", {"request": request, "error": None})
+def login_page(request: Request, db: Session = Depends(get_db)):
+ # Allow public registration only while no admin exists
+ registration_open = not has_admin(db)
+ return templates.TemplateResponse("login.html", {"request": request, "error": None, "registration_open": registration_open})
@router.get("/computers/{computer_id}", response_class=HTMLResponse)
@@ -153,3 +160,64 @@ def notifications_page(request: Request, db: Session = Depends(get_db)):
"unread_count": get_unread_count(db),
},
)
+
+
+# Admin pages
+
+@router.get("/admin/users", response_class=HTMLResponse)
+def admin_users_page(request: Request, db: Session = Depends(get_db)):
+ current_user = user_or_redirect(request, db)
+ if not current_user or current_user.role != "admin":
+ return RedirectResponse(url="/login")
+
+ users = db.query(User).order_by(User.username).all()
+ return templates.TemplateResponse(
+ "admin_users.html",
+ {
+ "request": request,
+ "user": current_user,
+ "users": users,
+ "unread_count": get_unread_count(db),
+ },
+ )
+
+
+@router.get("/admin/audit", response_class=HTMLResponse)
+def admin_audit_page(request: Request, db: Session = Depends(get_db)):
+ current_user = user_or_redirect(request, db)
+ if not current_user or current_user.role != "admin":
+ return RedirectResponse(url="/login")
+
+ audit = (
+ db.query(AuditLog)
+ .order_by(desc(AuditLog.timestamp))
+ .limit(200)
+ .all()
+ )
+ return templates.TemplateResponse(
+ "admin_audit.html",
+ {
+ "request": request,
+ "user": current_user,
+ "audit": audit,
+ "unread_count": get_unread_count(db),
+ },
+ )
+
+
+@router.get("/admin/settings", response_class=HTMLResponse)
+def admin_settings_page(request: Request, db: Session = Depends(get_db)):
+ current_user = user_or_redirect(request, db)
+ if not current_user or current_user.role != "admin":
+ return RedirectResponse(url="/login")
+
+ settings = get_all_settings(db)
+ return templates.TemplateResponse(
+ "admin_settings.html",
+ {
+ "request": request,
+ "user": current_user,
+ "settings": settings,
+ "unread_count": get_unread_count(db),
+ },
+ )
diff --git a/server/schemas.py b/server/schemas.py
index 42d7121..74f4603 100644
--- a/server/schemas.py
+++ b/server/schemas.py
@@ -17,6 +17,20 @@ class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
+class UserUpdate(BaseModel):
+ role: Optional[str] = None
+ password: Optional[str] = None
+
+
+class UserListOut(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
@@ -90,3 +104,27 @@ class NotificationOut(BaseModel):
read_at: Optional[datetime]
model_config = ConfigDict(from_attributes=True)
+
+
+class AuditLogOut(BaseModel):
+ id: int
+ user_id: Optional[int]
+ username: Optional[str]
+ action: str
+ details: Optional[str]
+ timestamp: Optional[datetime]
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class SettingOut(BaseModel):
+ key: str
+ value: Optional[str]
+
+
+class SettingsUpdate(BaseModel):
+ AGENT_TOKEN: Optional[str] = None
+ OFFLINE_THRESHOLD_MINUTES: Optional[int] = None
+ NOTIFICATION_WEBHOOK_URL: Optional[str] = None
+ NOTIFICATION_TELEGRAM_BOT_TOKEN: Optional[str] = None
+ NOTIFICATION_TELEGRAM_CHAT_ID: Optional[str] = None
diff --git a/server/settings_store.py b/server/settings_store.py
new file mode 100644
index 0000000..2ed9112
--- /dev/null
+++ b/server/settings_store.py
@@ -0,0 +1,52 @@
+from typing import Optional
+from sqlalchemy.orm import Session
+from models import Setting
+from config import get_settings
+
+_settings_cache = {}
+
+
+def get_setting(db: Session, key: str) -> Optional[str]:
+ """Get setting from DB, fallback to .env config."""
+ s = db.query(Setting).filter(Setting.key == key).first()
+ if s:
+ return s.value
+
+ config = get_settings()
+ return getattr(config, key, None)
+
+
+def set_setting(db: Session, key: str, value: Optional[str]) -> Setting:
+ s = db.query(Setting).filter(Setting.key == key).first()
+ if s:
+ s.value = value
+ else:
+ s = Setting(key=key, value=value)
+ db.add(s)
+ db.commit()
+ db.refresh(s)
+ _settings_cache[key] = value
+ return s
+
+
+def get_all_settings(db: Session) -> dict:
+ """Return merged DB settings and .env defaults."""
+ config = get_settings()
+ result = {}
+ keys = [
+ "AGENT_TOKEN",
+ "OFFLINE_THRESHOLD_MINUTES",
+ "NOTIFICATION_WEBHOOK_URL",
+ "NOTIFICATION_TELEGRAM_BOT_TOKEN",
+ "NOTIFICATION_TELEGRAM_CHAT_ID",
+ ]
+ for key in keys:
+ db_val = db.query(Setting).filter(Setting.key == key).first()
+ value = db_val.value if db_val else getattr(config, key, None)
+ if key == "OFFLINE_THRESHOLD_MINUTES" and value is not None:
+ try:
+ value = int(value)
+ except (TypeError, ValueError):
+ value = getattr(config, key, None)
+ result[key] = value
+ return result
diff --git a/server/templates/admin_audit.html b/server/templates/admin_audit.html
new file mode 100644
index 0000000..f25895c
--- /dev/null
+++ b/server/templates/admin_audit.html
@@ -0,0 +1,35 @@
+{% extends "base.html" %}
+
+{% block title %}Аудит-лог — InfoUser Monitor{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ | Время |
+ Пользователь |
+ Действие |
+ Детали |
+
+
+
+ {% for a in audit %}
+
+ | {{ a.timestamp.strftime('%Y-%m-%d %H:%M:%S') if a.timestamp else '-' }} |
+ {{ a.username or '-' }} |
+ {{ a.action }} |
+ {{ a.details or '-' }} |
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/server/templates/admin_settings.html b/server/templates/admin_settings.html
new file mode 100644
index 0000000..a153729
--- /dev/null
+++ b/server/templates/admin_settings.html
@@ -0,0 +1,69 @@
+{% extends "base.html" %}
+
+{% block title %}Настройки — InfoUser Monitor{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Настройки сохранены
+
+
+
+
+{% endblock %}
diff --git a/server/templates/admin_users.html b/server/templates/admin_users.html
new file mode 100644
index 0000000..ede199c
--- /dev/null
+++ b/server/templates/admin_users.html
@@ -0,0 +1,124 @@
+{% extends "base.html" %}
+
+{% block title %}Управление пользователями — InfoUser Monitor{% endblock %}
+
+{% block content %}
+
+
Пользователи
+
← Назад
+
+
+
+
+
Создать пользователя
+
+
+
+
+
+
+
+
+ | ID |
+ Логин |
+ Роль |
+ Создан |
+ |
+
+
+
+ {% for u in users %}
+
+ | {{ u.id }} |
+ {{ u.username }} |
+
+ {{ u.role }}
+ |
+ {{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else '-' }} |
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/server/templates/base.html b/server/templates/base.html
index 5d17205..a71bfe3 100644
--- a/server/templates/base.html
+++ b/server/templates/base.html
@@ -14,8 +14,21 @@