Add RBAC: admin user management, audit log, runtime settings, delete PC
This commit is contained in:
@@ -74,6 +74,20 @@ def require_admin(user: models.User = Depends(get_current_user)):
|
|||||||
return 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]:
|
def create_default_admin(db: Session) -> Optional[models.User]:
|
||||||
if not settings.CREATE_ADMIN_ON_STARTUP:
|
if not settings.CREATE_ADMIN_ON_STARTUP:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -24,3 +24,17 @@ def get_db():
|
|||||||
|
|
||||||
def create_tables():
|
def create_tables():
|
||||||
Base.metadata.create_all(bind=engine)
|
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()
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
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 auth import create_default_admin
|
||||||
from routes import auth_router, api_router, web_router
|
from routes import auth_router, api_router, web_router
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ from routes import auth_router, api_router, web_router
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
create_tables()
|
create_tables()
|
||||||
|
run_migrations()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
create_default_admin(db)
|
create_default_admin(db)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class AuditLog(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
username = Column(String, nullable=True)
|
||||||
action = Column(String, nullable=False)
|
action = Column(String, nullable=False)
|
||||||
details = Column(Text, nullable=True)
|
details = Column(Text, nullable=True)
|
||||||
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
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())
|
sent_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
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())
|
||||||
|
|
||||||
|
|||||||
+25
-9
@@ -6,11 +6,19 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
from models import Computer, Notification
|
from models import Computer, Notification
|
||||||
|
from settings_store import get_setting
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def create_notification(db: Session, computer: Computer, ntype: str, title: str, message: str) -> Notification:
|
||||||
n = Notification(
|
n = Notification(
|
||||||
computer_id=computer.id,
|
computer_id=computer.id,
|
||||||
@@ -21,13 +29,17 @@ def create_notification(db: Session, computer: Computer, ntype: str, title: str,
|
|||||||
db.add(n)
|
db.add(n)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(n)
|
db.refresh(n)
|
||||||
send_external_notifications(n, computer)
|
send_external_notifications(db, n, computer)
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def check_offline_computers(db: Session):
|
def check_offline_computers(db: Session):
|
||||||
"""Mark online computers that missed the threshold as offline and create notifications."""
|
"""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 = (
|
stale = (
|
||||||
db.query(Computer)
|
db.query(Computer)
|
||||||
.filter(Computer.status == "online", Computer.last_seen < threshold)
|
.filter(Computer.status == "online", Computer.last_seen < threshold)
|
||||||
@@ -49,7 +61,7 @@ def check_offline_computers(db: Session):
|
|||||||
if not existing:
|
if not existing:
|
||||||
msg = (
|
msg = (
|
||||||
f"ПК {computer.hostname} не присылал данные более "
|
f"ПК {computer.hostname} не присылал данные более "
|
||||||
f"{settings.OFFLINE_THRESHOLD_MINUTES} минут. "
|
f"{threshold_minutes} минут. "
|
||||||
f"Последний пользователь: {computer.current_user or '-'}, "
|
f"Последний пользователь: {computer.current_user or '-'}, "
|
||||||
f"IP: {computer.current_ip or '-'}"
|
f"IP: {computer.current_ip or '-'}"
|
||||||
)
|
)
|
||||||
@@ -57,11 +69,15 @@ def check_offline_computers(db: Session):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def send_external_notifications(notification: Notification, computer: Computer):
|
def send_external_notifications(db: Session, notification: Notification, computer: Computer):
|
||||||
if settings.NOTIFICATION_WEBHOOK_URL:
|
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:
|
try:
|
||||||
requests.post(
|
requests.post(
|
||||||
settings.NOTIFICATION_WEBHOOK_URL,
|
webhook_url,
|
||||||
json={
|
json={
|
||||||
"type": notification.type,
|
"type": notification.type,
|
||||||
"title": notification.title,
|
"title": notification.title,
|
||||||
@@ -75,13 +91,13 @@ def send_external_notifications(notification: Notification, computer: Computer):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Webhook notification failed: %s", 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:
|
try:
|
||||||
text = f"*{notification.title}*\n\n{notification.message}"
|
text = f"*{notification.title}*\n\n{notification.message}"
|
||||||
requests.post(
|
requests.post(
|
||||||
f"https://api.telegram.org/bot{settings.NOTIFICATION_TELEGRAM_BOT_TOKEN}/sendMessage",
|
f"https://api.telegram.org/bot{telegram_token}/sendMessage",
|
||||||
json={
|
json={
|
||||||
"chat_id": settings.NOTIFICATION_TELEGRAM_CHAT_ID,
|
"chat_id": telegram_chat,
|
||||||
"text": text,
|
"text": text,
|
||||||
"parse_mode": "Markdown",
|
"parse_mode": "Markdown",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,10 +10,20 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Computer, Heartbeat, Notification
|
from models import Computer, Heartbeat, Notification, AuditLog
|
||||||
from schemas import HeartbeatPayload, ComputerOut, ComputerDetailOut, HeartbeatOut, NotificationOut
|
from schemas import (
|
||||||
from auth import get_current_user
|
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 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"])
|
router = APIRouter(prefix="/api", tags=["api"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -33,7 +43,8 @@ def heartbeat(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
token = get_agent_token(request)
|
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")
|
raise HTTPException(status_code=401, detail="Invalid agent token")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -140,6 +151,23 @@ def get_computer(
|
|||||||
return 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")
|
@router.get("/computers/export/csv")
|
||||||
def export_csv(
|
def export_csv(
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
@@ -230,3 +258,40 @@ def read_all_notifications(
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
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)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, timezone
|
from typing import Optional, List
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from auth import (
|
from auth import (
|
||||||
@@ -12,10 +11,12 @@ from auth import (
|
|||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
verify_token,
|
verify_token,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
|
require_admin,
|
||||||
create_default_admin,
|
create_default_admin,
|
||||||
|
log_action,
|
||||||
)
|
)
|
||||||
from models import User
|
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"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -87,3 +88,87 @@ def me(user: User = Depends(get_current_user)):
|
|||||||
def logout(response: Response):
|
def logout(response: Response):
|
||||||
response.delete_cookie("access_token")
|
response.delete_cookie("access_token")
|
||||||
return {"ok": True}
|
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}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ from datetime import datetime, timezone, timedelta
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from auth import verify_token
|
from auth import verify_token, require_admin
|
||||||
from models import Computer, Heartbeat, User, Notification
|
from models import Computer, Heartbeat, User, Notification, AuditLog, Setting
|
||||||
from notifications import check_offline_computers, get_unread_count
|
from notifications import check_offline_computers, get_unread_count
|
||||||
|
from settings_store import get_all_settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="templates")
|
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()
|
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)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
def index(
|
def index(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -85,8 +90,10 @@ def index(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
def login_page(request: Request):
|
def login_page(request: Request, db: Session = Depends(get_db)):
|
||||||
return templates.TemplateResponse("login.html", {"request": request, "error": None})
|
# 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)
|
@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),
|
"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),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ class UserOut(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
@@ -90,3 +104,27 @@ class NotificationOut(BaseModel):
|
|||||||
read_at: Optional[datetime]
|
read_at: Optional[datetime]
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Аудит-лог — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Аудит-лог</h2>
|
||||||
|
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th>Детали</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in audit %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">{{ a.timestamp.strftime('%Y-%m-%d %H:%M:%S') if a.timestamp else '-' }}</td>
|
||||||
|
<td>{{ a.username or '-' }}</td>
|
||||||
|
<td><span class="badge bg-info">{{ a.action }}</span></td>
|
||||||
|
<td class="text-muted small">{{ a.details or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Настройки — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Настройки</h2>
|
||||||
|
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">AGENT_TOKEN</label>
|
||||||
|
<input type="text" class="form-control" id="AGENT_TOKEN" value="{{ settings.AGENT_TOKEN or '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">OFFLINE_THRESHOLD_MINUTES</label>
|
||||||
|
<input type="number" class="form-control" id="OFFLINE_THRESHOLD_MINUTES" value="{{ settings.OFFLINE_THRESHOLD_MINUTES or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">NOTIFICATION_WEBHOOK_URL</label>
|
||||||
|
<input type="text" class="form-control" id="NOTIFICATION_WEBHOOK_URL" value="{{ settings.NOTIFICATION_WEBHOOK_URL or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">NOTIFICATION_TELEGRAM_BOT_TOKEN</label>
|
||||||
|
<input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_BOT_TOKEN" value="{{ settings.NOTIFICATION_TELEGRAM_BOT_TOKEN or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">NOTIFICATION_TELEGRAM_CHAT_ID</label>
|
||||||
|
<input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_CHAT_ID" value="{{ settings.NOTIFICATION_TELEGRAM_CHAT_ID or '' }}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<div id="settings-error" class="alert alert-danger mt-3 d-none"></div>
|
||||||
|
<div id="settings-success" class="alert alert-success mt-3 d-none">Настройки сохранены</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const err = document.getElementById('settings-error');
|
||||||
|
const ok = document.getElementById('settings-success');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
ok.classList.add('d-none');
|
||||||
|
const body = {
|
||||||
|
AGENT_TOKEN: document.getElementById('AGENT_TOKEN').value,
|
||||||
|
OFFLINE_THRESHOLD_MINUTES: parseInt(document.getElementById('OFFLINE_THRESHOLD_MINUTES').value),
|
||||||
|
NOTIFICATION_WEBHOOK_URL: document.getElementById('NOTIFICATION_WEBHOOK_URL').value,
|
||||||
|
NOTIFICATION_TELEGRAM_BOT_TOKEN: document.getElementById('NOTIFICATION_TELEGRAM_BOT_TOKEN').value,
|
||||||
|
NOTIFICATION_TELEGRAM_CHAT_ID: document.getElementById('NOTIFICATION_TELEGRAM_CHAT_ID').value,
|
||||||
|
};
|
||||||
|
const res = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
ok.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
err.textContent = data.detail || 'Ошибка';
|
||||||
|
err.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Управление пользователями — InfoUser Monitor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Пользователи</h2>
|
||||||
|
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Создать пользователя</h5>
|
||||||
|
<form id="create-user-form" class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="text" class="form-control" id="new-username" placeholder="Логин" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="password" class="form-control" id="new-password" placeholder="Пароль" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select class="form-select" id="new-role">
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Создать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="create-error" class="alert alert-danger mt-3 d-none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Логин</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr data-id="{{ u.id }}">
|
||||||
|
<td>{{ u.id }}</td>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if u.role == 'admin' %}bg-danger{% else %}bg-secondary{% endif %}">{{ u.role }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary change-role" data-id="{{ u.id }}" data-username="{{ u.username }}" data-role="{{ u.role }}">Роль</button>
|
||||||
|
<button class="btn btn-sm btn-outline-warning reset-password" data-id="{{ u.id }}" data-username="{{ u.username }}">Пароль</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger delete-user" data-id="{{ u.id }}" data-username="{{ u.username }}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const err = document.getElementById('create-error');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
const res = await fetch('/api/auth/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('new-username').value,
|
||||||
|
password: document.getElementById('new-password').value,
|
||||||
|
role: document.getElementById('new-role').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
err.textContent = data.detail || 'Ошибка';
|
||||||
|
err.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.change-role').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const newRole = btn.dataset.role === 'admin' ? 'viewer' : 'admin';
|
||||||
|
if (!confirm(`Сменить роль ${btn.dataset.username} на ${newRole}?`)) return;
|
||||||
|
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({role: newRole}),
|
||||||
|
});
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
else alert('Ошибка смены роли');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.reset-password').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const password = prompt(`Новый пароль для ${btn.dataset.username}:`);
|
||||||
|
if (!password) return;
|
||||||
|
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({password}),
|
||||||
|
});
|
||||||
|
if (res.ok) alert('Пароль изменён');
|
||||||
|
else alert('Ошибка смены пароля');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.delete-user').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Удалить пользователя ${btn.dataset.username}?`)) return;
|
||||||
|
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {method: 'DELETE'});
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
else alert('Ошибка удаления');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -14,8 +14,21 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="/">InfoUser Monitor</a>
|
<a class="navbar-brand" href="/">InfoUser Monitor</a>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<div class="collapse navbar-collapse">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav ms-auto align-items-center">
|
<ul class="navbar-nav ms-auto align-items-center">
|
||||||
|
{% if user.role == 'admin' %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">Admin</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item" href="/admin/users">Пользователи</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/admin/settings">Настройки</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/admin/audit">Аудит-лог</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li class="nav-item me-3">
|
<li class="nav-item me-3">
|
||||||
<a href="/notifications" class="nav-link position-relative">
|
<a href="/notifications" class="nav-link position-relative">
|
||||||
<i class="bi bi-bell"></i>
|
<i class="bi bi-bell"></i>
|
||||||
|
|||||||
@@ -81,7 +81,12 @@
|
|||||||
<td>{{ c.current_ram_percent | round(1) if c.current_ram_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>{{ 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 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>
|
<td>
|
||||||
|
<a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a>
|
||||||
|
{% if user.role == 'admin' %}
|
||||||
|
<button class="btn btn-sm btn-outline-danger delete-pc" data-id="{{ c.id }}" data-hostname="{{ c.hostname }}">Удалить</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -94,10 +99,23 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
const isAdmin = {{ 'true' if user.role == 'admin' else 'false' }};
|
||||||
|
|
||||||
function currentQuery() {
|
function currentQuery() {
|
||||||
return new URLSearchParams(window.location.search).toString();
|
return new URLSearchParams(window.location.search).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteComputer(id, hostname) {
|
||||||
|
if (!confirm(`Удалить ПК ${hostname}?`)) return;
|
||||||
|
const res = await fetch(`/api/computers/${id}`, {method: 'DELETE'});
|
||||||
|
if (res.ok) location.reload();
|
||||||
|
else alert('Ошибка удаления');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.delete-pc').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => deleteComputer(btn.dataset.id, btn.dataset.hostname));
|
||||||
|
});
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/computers?' + currentQuery());
|
const res = await fetch('/api/computers?' + currentQuery());
|
||||||
@@ -114,9 +132,15 @@ async function loadDashboard() {
|
|||||||
<td>${c.current_ram_percent != null ? c.current_ram_percent.toFixed(1) : '-'}%</td>
|
<td>${c.current_ram_percent != null ? c.current_ram_percent.toFixed(1) : '-'}%</td>
|
||||||
<td>${escapeHtml(c.os_info || '-')}</td>
|
<td>${escapeHtml(c.os_info || '-')}</td>
|
||||||
<td class="last-seen">${c.last_seen ? c.last_seen.replace('T', ' ').slice(0, 16) : '-'}</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>
|
<td>
|
||||||
|
<a href="/computers/${c.id}" class="btn btn-sm btn-outline-primary">Детали</a>
|
||||||
|
${isAdmin ? `<button class="btn btn-sm btn-outline-danger delete-pc" data-id="${c.id}" data-hostname="${escapeHtml(c.hostname)}">Удалить</button>` : ''}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
document.querySelectorAll('.delete-pc').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => deleteComputer(btn.dataset.id, btn.dataset.hostname));
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Dashboard refresh failed', e);
|
console.error('Dashboard refresh failed', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,21 @@
|
|||||||
<div class="alert alert-danger mt-3">{{ error }}</div>
|
<div class="alert alert-danger mt-3">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="login-error" class="alert alert-danger mt-3 d-none"></div>
|
<div id="login-error" class="alert alert-danger mt-3 d-none"></div>
|
||||||
|
|
||||||
|
{% if registration_open %}
|
||||||
|
<hr>
|
||||||
|
<h6 class="text-center">Или создайте первого администратора</h6>
|
||||||
|
<form id="register-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" class="form-control" id="reg-username" placeholder="Логин" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="password" class="form-control" id="reg-password" placeholder="Пароль" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-outline-secondary w-100">Зарегистрироваться</button>
|
||||||
|
</form>
|
||||||
|
<div id="register-error" class="alert alert-danger mt-3 d-none"></div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center mt-3 text-muted small">
|
<div class="text-center mt-3 text-muted small">
|
||||||
@@ -55,5 +70,28 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|||||||
err.classList.remove('d-none');
|
err.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if registration_open %}
|
||||||
|
document.getElementById('register-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const err = document.getElementById('register-error');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
const res = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('reg-username').value,
|
||||||
|
password: document.getElementById('reg-password').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
err.textContent = data.detail || 'Ошибка регистрации';
|
||||||
|
err.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user