first commit
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
**/__pycache__
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.mypy_cache
|
||||||
|
**/.ruff_cache
|
||||||
|
**/.venv
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.angular
|
||||||
|
.env
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
SECRET_KEY=change-me-before-production
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=7899999
|
||||||
|
ALLOW_REGISTRATION=true
|
||||||
|
DEFAULT_ADMIN_USERNAME=admin
|
||||||
|
DEFAULT_ADMIN_PASSWORD=admin
|
||||||
|
APP_PORT=5581
|
||||||
|
API_PREFIX=/api
|
||||||
|
DATA_DIR=/app/storage
|
||||||
|
DATABASE_URL=sqlite:////app/storage/db/routeros_backup_next.db
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Backend runtime
|
||||||
|
backend/storage/routeros_backup_next.db
|
||||||
|
backend/data/
|
||||||
|
backend/storage/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Node / Angular
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.angular/
|
||||||
|
frontend/.cache/
|
||||||
|
|
||||||
|
# OS / editors
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
*.zip
|
||||||
|
docker-data/db/*
|
||||||
|
docker-data/*.rsc
|
||||||
|
docker-data/*.backup
|
||||||
|
storage/*
|
||||||
18
README.md
Normal file
18
README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Mikrotik Backup System
|
||||||
|
|
||||||
|
## Deploy in docker
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
edit SECRET_KEY & DEFAULT_ADMIN_PASSWORD
|
||||||
|
and run:
|
||||||
|
|
||||||
|
`bash start_prod.sh`
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
`docker compose up -d`
|
||||||
|
|
||||||
|
## Default app port:
|
||||||
|
`http://127.0.0.1:5581`
|
||||||
4
backend/.env.example
Normal file
4
backend/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
APP_PORT=8080
|
||||||
|
API_PREFIX=/api
|
||||||
|
DATA_DIR=/app/storage
|
||||||
|
DATABASE_URL=sqlite:////app/storage/routeros_backup_next.db
|
||||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY backend /app
|
||||||
|
RUN mkdir -p /app/storage
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
12
backend/app/api/__init__.py
Normal file
12
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.routes import auth, backups, dashboard, health, logs, routers, settings
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(auth.router, prefix='/auth', tags=['auth'])
|
||||||
|
api_router.include_router(dashboard.router, prefix='/dashboard', tags=['dashboard'])
|
||||||
|
api_router.include_router(routers.router, prefix='/routers', tags=['routers'])
|
||||||
|
api_router.include_router(backups.router, prefix='/backups', tags=['backups'])
|
||||||
|
api_router.include_router(settings.router, prefix='/settings', tags=['settings'])
|
||||||
|
api_router.include_router(logs.router, prefix='/logs', tags=['logs'])
|
||||||
|
api_router.include_router(health.router, tags=['health'])
|
||||||
40
backend/app/api/deps.py
Normal file
40
backend/app/api/deps.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
|
username: str | None = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError as exc:
|
||||||
|
raise credentials_exception from exc
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
106
backend/app/api/routes/auth.py
Normal file
106
backend/app/api/routes/auth.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_access_token, get_password_hash, verify_password
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.auth import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
TokenResponse,
|
||||||
|
UpdateUserPreferencesRequest,
|
||||||
|
UserResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse)
|
||||||
|
def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
||||||
|
if not settings.allow_registration:
|
||||||
|
raise HTTPException(status_code=403, detail="Registration is disabled")
|
||||||
|
existing = db.query(User).filter(User.username == payload.username).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Username already exists")
|
||||||
|
user = User(username=payload.username, password_hash=get_password_hash(payload.password))
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(request: Request, db: Session = Depends(get_db)):
|
||||||
|
username = None
|
||||||
|
password = None
|
||||||
|
content_type = (request.headers.get("content-type") or "").lower()
|
||||||
|
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
username = payload.get("username")
|
||||||
|
password = payload.get("password")
|
||||||
|
else:
|
||||||
|
form_data = await request.form()
|
||||||
|
username = form_data.get("username")
|
||||||
|
password = form_data.get("password")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(status_code=422, detail="Username and password are required")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user or not verify_password(password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
token = create_access_token(
|
||||||
|
subject=user.username,
|
||||||
|
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
|
||||||
|
)
|
||||||
|
return TokenResponse(access_token=token, user=UserResponse.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
def me(current_user: User = Depends(get_current_user)):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences", response_model=UserResponse)
|
||||||
|
def update_preferences(
|
||||||
|
payload: UpdateUserPreferencesRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
preferred_language = (payload.preferred_language or 'pl').strip().lower()
|
||||||
|
preferred_font = (payload.preferred_font or 'default').strip().lower()
|
||||||
|
|
||||||
|
if preferred_language not in {'pl', 'en', 'es', 'no'}:
|
||||||
|
raise HTTPException(status_code=422, detail='Unsupported language')
|
||||||
|
if preferred_font not in {'default', 'adwaita_mono', 'hack'}:
|
||||||
|
raise HTTPException(status_code=422, detail='Unsupported font')
|
||||||
|
|
||||||
|
current_user.preferred_language = preferred_language
|
||||||
|
current_user.preferred_font = preferred_font
|
||||||
|
db.add(current_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(current_user)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
def change_password(
|
||||||
|
payload: ChangePasswordRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not verify_password(payload.current_password, current_user.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is invalid")
|
||||||
|
current_user.password_hash = get_password_hash(payload.new_password)
|
||||||
|
db.add(current_user)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Password changed successfully"}
|
||||||
128
backend/app/api/routes/backups.py
Normal file
128
backend/app/api/routes/backups.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from datetime import date
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.backup import BackupDiffResponse, BackupResponse, BulkActionRequest
|
||||||
|
from app.services.backup_service import backup_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BackupResponse])
|
||||||
|
def list_backups(
|
||||||
|
search: str | None = Query(default=None),
|
||||||
|
backup_type: str | None = Query(default=None, pattern="^(export|binary)$"),
|
||||||
|
router_id: int | None = Query(default=None),
|
||||||
|
created_on: date | None = Query(default=None),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
order: str = Query(default="desc", pattern="^(asc|desc)$"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
return backup_service.list_backups(
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
search=search,
|
||||||
|
backup_type=backup_type,
|
||||||
|
router_id=router_id,
|
||||||
|
created_on=created_on,
|
||||||
|
sort_by=sort_by,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/router/{router_id}", response_model=list[BackupResponse])
|
||||||
|
def list_router_backups(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
return backup_service.list_router_backups(db, current_user, router_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/routers/export-all")
|
||||||
|
def export_all(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
return backup_service.export_all(db, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/routers/binary-all")
|
||||||
|
def binary_all(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
return backup_service.binary_all(db, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/router/{router_id}/export", response_model=BackupResponse)
|
||||||
|
def export_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
return backup_service.export_router(db, current_user, router_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/router/{router_id}/binary", response_model=BackupResponse)
|
||||||
|
def binary_backup(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
return backup_service.binary_backup(db, current_user, router_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/router/{router_id}/upload/{backup_id}")
|
||||||
|
def upload_to_router(router_id: int, backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
backup_service.upload_backup_to_router(db, current_user, router_id, backup_id)
|
||||||
|
return {"message": "Backup uploaded to router"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{backup_id}")
|
||||||
|
def delete_backup(backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
backup_service.delete_backup(db, current_user, backup_id)
|
||||||
|
return {"message": "Backup deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{backup_id}/download")
|
||||||
|
def download_backup(backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
backup = backup_service.get_backup_for_user(db, current_user, backup_id)
|
||||||
|
return FileResponse(path=backup.file_path, filename=backup.file_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{backup_id}/view")
|
||||||
|
def view_export(backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
backup = backup_service.get_backup_for_user(db, current_user, backup_id)
|
||||||
|
if backup.backup_type != "export":
|
||||||
|
raise HTTPException(status_code=400, detail="Only export backups can be viewed")
|
||||||
|
with open(backup.file_path, "r", encoding="utf-8", errors="ignore") as handle:
|
||||||
|
return {"content": handle.read(), "backup": BackupResponse.model_validate(backup_service._serialize_backup(backup))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{backup_id}/email")
|
||||||
|
def email_backup(backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
backup_service.email_backup(db, current_user, backup_id)
|
||||||
|
return {"message": "Backup sent by email"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{left_id}/diff/{right_id}", response_model=BackupDiffResponse)
|
||||||
|
def diff_backups(left_id: int, right_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
return backup_service.diff_backups(db, current_user, left_id, right_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{left_id}/diff/{right_id}/html", response_class=HTMLResponse)
|
||||||
|
def diff_backups_html(left_id: int, right_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
result = backup_service.diff_backups(db, current_user, left_id, right_id)
|
||||||
|
return HTMLResponse(result["diff_html"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk")
|
||||||
|
def bulk_action(payload: BulkActionRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
backups = [backup_service.get_backup_for_user(db, current_user, backup_id) for backup_id in payload.backup_ids]
|
||||||
|
if payload.action == "delete":
|
||||||
|
for backup in backups:
|
||||||
|
backup_service.delete_backup(db, current_user, backup.id, commit=False)
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"Deleted {len(backups)} backups"}
|
||||||
|
if payload.action == "download":
|
||||||
|
stream = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(stream, "w") as archive:
|
||||||
|
for backup in backups:
|
||||||
|
archive.write(backup.file_path, backup.file_name)
|
||||||
|
stream.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="backups.zip"'},
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported bulk action")
|
||||||
42
backend/app/api/routes/dashboard.py
Normal file
42
backend/app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.backup import Backup
|
||||||
|
from app.models.log import OperationLog
|
||||||
|
from app.models.router import Router
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.dashboard import DashboardResponse
|
||||||
|
from app.services.file_service import get_storage_stats
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DashboardResponse)
|
||||||
|
def get_dashboard(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
routers_count = db.query(func.count(Router.id)).filter(Router.owner_id == current_user.id).scalar() or 0
|
||||||
|
export_count = (
|
||||||
|
db.query(func.count(Backup.id))
|
||||||
|
.join(Router)
|
||||||
|
.filter(Router.owner_id == current_user.id, Backup.backup_type == "export")
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
binary_count = (
|
||||||
|
db.query(func.count(Backup.id))
|
||||||
|
.join(Router)
|
||||||
|
.filter(Router.owner_id == current_user.id, Backup.backup_type == "binary")
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
recent_logs = db.query(OperationLog).order_by(OperationLog.timestamp.desc()).limit(10).all()
|
||||||
|
storage = get_storage_stats()
|
||||||
|
return DashboardResponse(
|
||||||
|
routers_count=routers_count,
|
||||||
|
export_count=export_count,
|
||||||
|
binary_count=binary_count,
|
||||||
|
total_backups=export_count + binary_count,
|
||||||
|
storage=storage,
|
||||||
|
recent_logs=recent_logs,
|
||||||
|
)
|
||||||
19
backend/app/api/routes/health.py
Normal file
19
backend/app/api/routes/health.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health(db: Session = Depends(get_db)):
|
||||||
|
status = "ok"
|
||||||
|
try:
|
||||||
|
db.execute(text("SELECT 1"))
|
||||||
|
except Exception:
|
||||||
|
status = "error"
|
||||||
|
return {"status": status, "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||||
32
backend/app/api/routes/logs.py
Normal file
32
backend/app/api/routes/logs.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.log import OperationLog
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.log_service import log_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_logs(
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
return db.query(OperationLog).order_by(OperationLog.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/older-than/{days}")
|
||||||
|
def delete_logs(
|
||||||
|
days: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = current_user
|
||||||
|
if days < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Days must be >= 1")
|
||||||
|
deleted = log_service.delete_older_than(db, days)
|
||||||
|
return {"message": f"Deleted {deleted} logs", "deleted": deleted}
|
||||||
120
backend/app/api/routes/routers.py
Normal file
120
backend/app/api/routes/routers.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.router import Router
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.router import RouterCreate, RouterResponse, RouterTestConnection, RouterUpdate
|
||||||
|
from app.services.router_service import router_service
|
||||||
|
from app.services.settings_service import settings_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_router(router: Router, global_settings) -> RouterResponse:
|
||||||
|
has_router_key = bool((router.ssh_key or '').strip())
|
||||||
|
has_global_key = bool((global_settings.global_ssh_key or '').strip())
|
||||||
|
router_user = (router.ssh_user or '').strip() or None
|
||||||
|
router_password = (router.ssh_password or '').strip() or None
|
||||||
|
default_swos_user = (global_settings.default_switchos_username or '').strip() or None
|
||||||
|
default_swos_password = (global_settings.default_switchos_password or '').strip() or None
|
||||||
|
effective_username = router_user
|
||||||
|
uses_global_switchos_credentials = False
|
||||||
|
has_effective_password = bool(router_password)
|
||||||
|
|
||||||
|
if router.device_type == 'switchos':
|
||||||
|
effective_username = router_user or default_swos_user
|
||||||
|
uses_global_switchos_credentials = bool(
|
||||||
|
(not router_user and default_swos_user) or (not router_password and default_swos_password)
|
||||||
|
)
|
||||||
|
has_effective_password = bool(router_password or default_swos_password)
|
||||||
|
|
||||||
|
payload = RouterResponse.model_validate(router, from_attributes=True).model_dump()
|
||||||
|
payload['effective_username'] = effective_username
|
||||||
|
payload['uses_global_ssh_key'] = router.device_type == 'routeros' and has_global_key and not has_router_key
|
||||||
|
payload['has_effective_ssh_key'] = router.device_type == 'routeros' and (has_router_key or has_global_key)
|
||||||
|
payload['uses_global_switchos_credentials'] = uses_global_switchos_credentials
|
||||||
|
payload['has_effective_password'] = has_effective_password
|
||||||
|
payload['supports_export'] = router.device_type == 'routeros'
|
||||||
|
payload['supports_restore_upload'] = router.device_type == 'routeros'
|
||||||
|
return RouterResponse.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('', response_model=list[RouterResponse])
|
||||||
|
def list_routers(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
global_settings = settings_service.get_or_create(db)
|
||||||
|
routers = db.query(Router).filter(Router.owner_id == current_user.id).order_by(Router.created_at.desc()).all()
|
||||||
|
return [serialize_router(router, global_settings) for router in routers]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('', response_model=RouterResponse)
|
||||||
|
def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
router_data = payload.model_dump()
|
||||||
|
if router_data.get('device_type') == 'switchos' and router_data.get('ssh_user') is None:
|
||||||
|
router_data['ssh_user'] = ''
|
||||||
|
router = Router(**router_data, owner_id=current_user.id)
|
||||||
|
db.add(router)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(router)
|
||||||
|
global_settings = settings_service.get_or_create(db)
|
||||||
|
return serialize_router(router, global_settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/{router_id}', response_model=RouterResponse)
|
||||||
|
def get_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
|
||||||
|
if not router:
|
||||||
|
raise HTTPException(status_code=404, detail='Device not found')
|
||||||
|
global_settings = settings_service.get_or_create(db)
|
||||||
|
return serialize_router(router, global_settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put('/{router_id}', response_model=RouterResponse)
|
||||||
|
def update_router(router_id: int, payload: RouterUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
|
||||||
|
if not router:
|
||||||
|
raise HTTPException(status_code=404, detail='Device not found')
|
||||||
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
target_device_type = changes.get('device_type', router.device_type)
|
||||||
|
if target_device_type == 'switchos':
|
||||||
|
changes['ssh_key'] = None
|
||||||
|
if 'port' not in changes:
|
||||||
|
changes['port'] = 80
|
||||||
|
if changes.get('ssh_user') is None:
|
||||||
|
changes['ssh_user'] = ''
|
||||||
|
elif target_device_type == 'routeros' and 'port' not in changes and router.device_type != 'routeros':
|
||||||
|
changes['port'] = 22
|
||||||
|
if not changes.get('ssh_user'):
|
||||||
|
changes['ssh_user'] = router.ssh_user or 'admin'
|
||||||
|
for key, value in changes.items():
|
||||||
|
setattr(router, key, value)
|
||||||
|
db.add(router)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(router)
|
||||||
|
global_settings = settings_service.get_or_create(db)
|
||||||
|
return serialize_router(router, global_settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/{router_id}')
|
||||||
|
def delete_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
|
||||||
|
if not router:
|
||||||
|
raise HTTPException(status_code=404, detail='Device not found')
|
||||||
|
for backup in list(router.backups):
|
||||||
|
path = Path(backup.file_path)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
db.delete(router)
|
||||||
|
db.commit()
|
||||||
|
return {'message': 'Device deleted'}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/{router_id}/test-connection', response_model=RouterTestConnection)
|
||||||
|
def test_connection(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
|
||||||
|
if not router:
|
||||||
|
raise HTTPException(status_code=404, detail='Device not found')
|
||||||
|
global_settings = settings_service.get_or_create(db)
|
||||||
|
return router_service.test_connection(db, router, global_settings)
|
||||||
78
backend/app/api/routes/settings.py
Normal file
78
backend/app/api/routes/settings.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.core.security import verify_password
|
||||||
|
from app.models.settings import GlobalSettings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.settings import (
|
||||||
|
RevealSshKeyRequest,
|
||||||
|
RevealSshKeyResponse,
|
||||||
|
SchedulerStatusResponse,
|
||||||
|
SettingsResponse,
|
||||||
|
SettingsUpdate,
|
||||||
|
)
|
||||||
|
from app.services.notification_service import notification_service
|
||||||
|
from app.services.scheduler import scheduler_service
|
||||||
|
from app.services.settings_service import settings_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_settings(settings: GlobalSettings) -> SettingsResponse:
|
||||||
|
payload = SettingsResponse.model_validate(settings, from_attributes=True).model_dump()
|
||||||
|
payload['global_ssh_key'] = None
|
||||||
|
payload['has_global_ssh_key'] = bool((settings.global_ssh_key or '').strip())
|
||||||
|
payload['has_default_switchos_credentials'] = bool(
|
||||||
|
(settings.default_switchos_username or '').strip() or (settings.default_switchos_password or '').strip()
|
||||||
|
)
|
||||||
|
return SettingsResponse.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('', response_model=SettingsResponse)
|
||||||
|
def get_settings(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
_ = current_user
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
return serialize_settings(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/scheduler-status', response_model=SchedulerStatusResponse)
|
||||||
|
def get_scheduler_status(current_user: User = Depends(get_current_user)):
|
||||||
|
_ = current_user
|
||||||
|
return scheduler_service.scheduler_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put('', response_model=SettingsResponse)
|
||||||
|
def update_settings(payload: SettingsUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
_ = current_user
|
||||||
|
settings = settings_service.update(db, payload)
|
||||||
|
scheduler_service.reschedule()
|
||||||
|
return serialize_settings(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/reveal-ssh-key', response_model=RevealSshKeyResponse)
|
||||||
|
def reveal_ssh_key(
|
||||||
|
payload: RevealSshKeyRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not verify_password(payload.password, current_user.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail='Current password is invalid')
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
return {'global_ssh_key': settings.global_ssh_key}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/test-email')
|
||||||
|
def test_email(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
_ = current_user
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
notification_service.send_test_email(settings)
|
||||||
|
return {'message': 'Test email sent'}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/test-pushover')
|
||||||
|
def test_pushover(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
_ = current_user
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
notification_service.send_test_pushover(settings)
|
||||||
|
return {'message': 'Test pushover sent'}
|
||||||
33
backend/app/api/routes/swos_beta.py
Normal file
33
backend/app/api/routes/swos_beta.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.swos_beta import SwosBetaCredentials, SwosBetaProbeResponse
|
||||||
|
from app.services.swos_beta_service import swos_beta_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/probe', response_model=SwosBetaProbeResponse)
|
||||||
|
def probe_swos(payload: SwosBetaCredentials, current_user: User = Depends(get_current_user)):
|
||||||
|
del current_user
|
||||||
|
try:
|
||||||
|
return swos_beta_service.probe(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/download')
|
||||||
|
def download_swos_backup(payload: SwosBetaCredentials, current_user: User = Depends(get_current_user)):
|
||||||
|
del current_user
|
||||||
|
try:
|
||||||
|
backup = swos_beta_service.download_backup(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([backup.content]),
|
||||||
|
media_type=backup.content_type,
|
||||||
|
headers={'Content-Disposition': f'attachment; filename="{backup.filename}"'},
|
||||||
|
)
|
||||||
38
backend/app/core/config.py
Normal file
38
backend/app/core/config.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
app_name: str = 'Mikrotik Backup System'
|
||||||
|
app_env: str = 'development'
|
||||||
|
secret_key: str = 'change-me'
|
||||||
|
jwt_algorithm: str = 'HS256'
|
||||||
|
access_token_expire_minutes: int = 1440
|
||||||
|
database_url: str = 'sqlite:///./storage/routeros_backup_next.db'
|
||||||
|
data_dir: str = './storage'
|
||||||
|
allow_registration: bool = True
|
||||||
|
api_prefix: str = '/api'
|
||||||
|
timezone: str = 'Europe/Warsaw'
|
||||||
|
default_admin_username: str = 'admin'
|
||||||
|
default_admin_password: str = 'admin'
|
||||||
|
smtp_starttls: bool = True
|
||||||
|
smtp_timeout_seconds: int = 20
|
||||||
|
cors_origins: list[str] = Field(default_factory=lambda: ['http://localhost:4200', 'http://127.0.0.1:4200'])
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file='.env',
|
||||||
|
env_file_encoding='utf-8',
|
||||||
|
extra='ignore',
|
||||||
|
env_nested_delimiter='__',
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_path(self) -> Path:
|
||||||
|
path = Path(self.data_dir)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
88
backend/app/core/cron_utils.py
Normal file
88
backend/app/core/cron_utils.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
WEEKDAY_LABELS = {
|
||||||
|
'0': 'Sunday',
|
||||||
|
'1': 'Monday',
|
||||||
|
'2': 'Tuesday',
|
||||||
|
'3': 'Wednesday',
|
||||||
|
'4': 'Thursday',
|
||||||
|
'5': 'Friday',
|
||||||
|
'6': 'Saturday',
|
||||||
|
'7': 'Sunday',
|
||||||
|
'sun': 'Sunday',
|
||||||
|
'mon': 'Monday',
|
||||||
|
'tue': 'Tuesday',
|
||||||
|
'wed': 'Wednesday',
|
||||||
|
'thu': 'Thursday',
|
||||||
|
'fri': 'Friday',
|
||||||
|
'sat': 'Saturday',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CronValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cron_expression(expr: str, timezone_str: str) -> CronTrigger:
|
||||||
|
expr = (expr or '').strip()
|
||||||
|
if not expr:
|
||||||
|
raise CronValidationError('Cron expression cannot be empty')
|
||||||
|
|
||||||
|
parts = expr.split()
|
||||||
|
if len(parts) != 5:
|
||||||
|
raise CronValidationError('Cron expression must contain exactly 5 fields')
|
||||||
|
|
||||||
|
minute, hour, day, month, day_of_week = parts
|
||||||
|
try:
|
||||||
|
return CronTrigger(
|
||||||
|
minute=minute,
|
||||||
|
hour=hour,
|
||||||
|
day=day,
|
||||||
|
month=month,
|
||||||
|
day_of_week=day_of_week,
|
||||||
|
timezone=ZoneInfo(timezone_str),
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - APScheduler formats messages
|
||||||
|
raise CronValidationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cron_expression(expr: str, timezone_str: str) -> None:
|
||||||
|
parse_cron_expression(expr, timezone_str)
|
||||||
|
|
||||||
|
|
||||||
|
def preview_next_runs(expr: str, timezone_str: str, count: int = 3) -> list[datetime]:
|
||||||
|
trigger = parse_cron_expression(expr, timezone_str)
|
||||||
|
now = datetime.now(ZoneInfo(timezone_str))
|
||||||
|
previous = None
|
||||||
|
runs: list[datetime] = []
|
||||||
|
for _ in range(max(count, 0)):
|
||||||
|
next_run = trigger.get_next_fire_time(previous, now)
|
||||||
|
if not next_run:
|
||||||
|
break
|
||||||
|
runs.append(next_run)
|
||||||
|
previous = next_run
|
||||||
|
now = next_run
|
||||||
|
return runs
|
||||||
|
|
||||||
|
|
||||||
|
def describe_cron_expression(expr: str) -> str:
|
||||||
|
expr = (expr or '').strip()
|
||||||
|
if not expr:
|
||||||
|
return 'Disabled'
|
||||||
|
|
||||||
|
parts = expr.split()
|
||||||
|
if len(parts) != 5:
|
||||||
|
return 'Custom cron'
|
||||||
|
|
||||||
|
minute, hour, day, month, day_of_week = parts
|
||||||
|
if minute.isdigit() and hour.isdigit() and day == '*' and month == '*' and day_of_week == '*':
|
||||||
|
return f'Every day at {int(hour):02d}:{int(minute):02d}'
|
||||||
|
if minute.isdigit() and hour.isdigit() and day == '*' and month == '*' and day_of_week.lower() in WEEKDAY_LABELS:
|
||||||
|
weekday = WEEKDAY_LABELS[day_of_week.lower()]
|
||||||
|
return f'Every {weekday} at {int(hour):02d}:{int(minute):02d}'
|
||||||
|
return 'Custom cron'
|
||||||
22
backend/app/core/security.py
Normal file
22
backend/app/core/security.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
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(subject: str, expires_delta: timedelta) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
to_encode = {"sub": subject, "exp": expire}
|
||||||
|
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||||
7
backend/app/db/base.py
Normal file
7
backend/app/db/base.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from app.models.backup import Backup
|
||||||
|
from app.models.log import OperationLog
|
||||||
|
from app.models.router import Router
|
||||||
|
from app.models.settings import GlobalSettings
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
__all__ = ["User", "Router", "Backup", "OperationLog", "GlobalSettings"]
|
||||||
84
backend/app/db/session.py
Normal file
84
backend/app/db/session.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sqlite_parent(database_url: str) -> None:
|
||||||
|
if not database_url.startswith('sqlite:///'):
|
||||||
|
return
|
||||||
|
relative_path = database_url.removeprefix('sqlite:///')
|
||||||
|
if not relative_path or relative_path == ':memory:':
|
||||||
|
return
|
||||||
|
db_path = Path(relative_path)
|
||||||
|
if not db_path.is_absolute():
|
||||||
|
db_path = Path.cwd() / db_path
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_sqlite_parent(settings.database_url)
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.database_url,
|
||||||
|
connect_args={'check_same_thread': False} if settings.database_url.startswith('sqlite') else {},
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column(table_name: str, column_name: str, ddl: str) -> None:
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing = {column['name'] for column in inspector.get_columns(table_name)}
|
||||||
|
if column_name in existing:
|
||||||
|
return
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {ddl}'))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_lightweight_migrations() -> None:
|
||||||
|
tables = set(inspect(engine).get_table_names())
|
||||||
|
if 'global_settings' in tables:
|
||||||
|
_ensure_column('global_settings', 'connection_test_interval_minutes', 'INTEGER DEFAULT 0')
|
||||||
|
_ensure_column('global_settings', 'default_switchos_username', 'VARCHAR(120)')
|
||||||
|
_ensure_column('global_settings', 'default_switchos_password', 'VARCHAR(255)')
|
||||||
|
if 'users' in tables:
|
||||||
|
_ensure_column('users', 'preferred_language', "VARCHAR(8) DEFAULT 'pl' NOT NULL")
|
||||||
|
_ensure_column('users', 'preferred_font', "VARCHAR(32) DEFAULT 'default' NOT NULL")
|
||||||
|
if 'routers' in tables:
|
||||||
|
_ensure_column('routers', 'device_type', "VARCHAR(32) DEFAULT 'routeros' NOT NULL")
|
||||||
|
_ensure_column('routers', 'last_connection_status', 'BOOLEAN')
|
||||||
|
_ensure_column('routers', 'last_connection_tested_at', 'DATETIME')
|
||||||
|
_ensure_column('routers', 'last_connection_error', 'TEXT')
|
||||||
|
_ensure_column('routers', 'last_connection_hostname', 'VARCHAR(255)')
|
||||||
|
_ensure_column('routers', 'last_connection_model', 'VARCHAR(255)')
|
||||||
|
_ensure_column('routers', 'last_connection_version', 'VARCHAR(255)')
|
||||||
|
_ensure_column('routers', 'last_connection_uptime', 'VARCHAR(255)')
|
||||||
|
_ensure_column('routers', 'last_connection_transport', 'VARCHAR(32)')
|
||||||
|
_ensure_column('routers', 'last_connection_server', 'VARCHAR(255)')
|
||||||
|
_ensure_column('routers', 'last_connection_auth_mode', 'VARCHAR(64)')
|
||||||
|
_ensure_column('routers', 'last_connection_http_status', 'VARCHAR(32)')
|
||||||
|
_ensure_column('routers', 'last_connection_backup_available', 'BOOLEAN')
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
import app.db.base # noqa: F401
|
||||||
|
from app.models.settings import GlobalSettings
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_run_lightweight_migrations()
|
||||||
|
with SessionLocal() as db:
|
||||||
|
if not db.query(User).first():
|
||||||
|
db.add(
|
||||||
|
User(
|
||||||
|
username=settings.default_admin_username,
|
||||||
|
password_hash=get_password_hash(settings.default_admin_password),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
if not db.query(GlobalSettings).first():
|
||||||
|
db.add(GlobalSettings())
|
||||||
|
db.commit()
|
||||||
30
backend/app/main.py
Normal file
30
backend/app/main.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api import api_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import init_db
|
||||||
|
from app.services.scheduler import scheduler_service
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
init_db()
|
||||||
|
scheduler_service.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
scheduler_service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name, version="1.1.0", lifespan=lifespan)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.include_router(api_router, prefix=settings.api_prefix)
|
||||||
19
backend/app/models/backup.py
Normal file
19
backend/app/models/backup.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Backup(Base):
|
||||||
|
__tablename__ = "backups"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
router_id = Column(Integer, ForeignKey("routers.id"), nullable=False, index=True)
|
||||||
|
file_path = Column(String(500), nullable=False)
|
||||||
|
file_name = Column(String(255), nullable=False)
|
||||||
|
backup_type = Column(String(50), nullable=False, default="export")
|
||||||
|
checksum = Column(String(64), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
router = relationship("Router", back_populates="backups")
|
||||||
12
backend/app/models/log.py
Normal file
12
backend/app/models/log.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy import Column, DateTime, Integer, Text
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class OperationLog(Base):
|
||||||
|
__tablename__ = "operation_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
timestamp = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
34
backend/app/models/router.py
Normal file
34
backend/app/models/router.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Router(Base):
|
||||||
|
__tablename__ = "routers"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
name = Column(String(120), nullable=False)
|
||||||
|
device_type = Column(String(32), nullable=False, default="routeros")
|
||||||
|
host = Column(String(255), nullable=False)
|
||||||
|
port = Column(Integer, nullable=False, default=22)
|
||||||
|
ssh_user = Column(String(120), nullable=False, default="admin")
|
||||||
|
ssh_key = Column(Text, nullable=True)
|
||||||
|
ssh_password = Column(String(255), nullable=True)
|
||||||
|
last_connection_status = Column(Boolean, nullable=True)
|
||||||
|
last_connection_tested_at = Column(DateTime, nullable=True)
|
||||||
|
last_connection_error = Column(Text, nullable=True)
|
||||||
|
last_connection_hostname = Column(String(255), nullable=True)
|
||||||
|
last_connection_model = Column(String(255), nullable=True)
|
||||||
|
last_connection_version = Column(String(255), nullable=True)
|
||||||
|
last_connection_uptime = Column(String(255), nullable=True)
|
||||||
|
last_connection_transport = Column(String(32), nullable=True)
|
||||||
|
last_connection_server = Column(String(255), nullable=True)
|
||||||
|
last_connection_auth_mode = Column(String(64), nullable=True)
|
||||||
|
last_connection_http_status = Column(String(32), nullable=True)
|
||||||
|
last_connection_backup_available = Column(Boolean, nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
backups = relationship("Backup", back_populates="router", cascade="all, delete-orphan")
|
||||||
28
backend/app/models/settings.py
Normal file
28
backend/app/models/settings.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from sqlalchemy import Boolean, Column, Integer, String, Text
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettings(Base):
|
||||||
|
__tablename__ = "global_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
backup_retention_days = Column(Integer, default=7)
|
||||||
|
log_retention_days = Column(Integer, default=7)
|
||||||
|
export_cron = Column(String(64), default="")
|
||||||
|
binary_cron = Column(String(64), default="")
|
||||||
|
retention_cron = Column(String(64), default="")
|
||||||
|
enable_auto_export = Column(Boolean, default=False)
|
||||||
|
connection_test_interval_minutes = Column(Integer, default=0)
|
||||||
|
global_ssh_key = Column(Text, nullable=True)
|
||||||
|
default_switchos_username = Column(String(120), nullable=True)
|
||||||
|
default_switchos_password = Column(String(255), nullable=True)
|
||||||
|
pushover_token = Column(String(255), nullable=True)
|
||||||
|
pushover_userkey = Column(String(255), nullable=True)
|
||||||
|
notify_failures_only = Column(Boolean, default=True)
|
||||||
|
smtp_host = Column(String(255), nullable=True)
|
||||||
|
smtp_port = Column(Integer, default=587)
|
||||||
|
smtp_login = Column(String(255), nullable=True)
|
||||||
|
smtp_password = Column(String(255), nullable=True)
|
||||||
|
smtp_notifications_enabled = Column(Boolean, default=False)
|
||||||
|
recipient_email = Column(String(255), nullable=True)
|
||||||
15
backend/app/models/user.py
Normal file
15
backend/app/models/user.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import Column, DateTime, Integer, String
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(120), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
preferred_language = Column(String(8), nullable=False, default='pl')
|
||||||
|
preferred_font = Column(String(32), nullable=False, default='default')
|
||||||
31
backend/app/schemas/auth.py
Normal file
31
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
preferred_language: str = 'pl'
|
||||||
|
preferred_font: str = 'default'
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: UserResponse
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=120)
|
||||||
|
password: str = Field(min_length=4, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=4, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserPreferencesRequest(BaseModel):
|
||||||
|
preferred_language: str = Field(default='pl', min_length=2, max_length=8)
|
||||||
|
preferred_font: str = Field(default='default', min_length=2, max_length=32)
|
||||||
50
backend/app/schemas/backup.py
Normal file
50
backend/app/schemas/backup.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class BackupResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
router_id: int
|
||||||
|
router_name: str | None = None
|
||||||
|
device_type: str = "routeros"
|
||||||
|
file_path: str
|
||||||
|
file_name: str
|
||||||
|
backup_type: str
|
||||||
|
checksum: str | None = None
|
||||||
|
file_size: int | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {'from_attributes': True}
|
||||||
|
|
||||||
|
|
||||||
|
class BackupDiffLine(BaseModel):
|
||||||
|
type: Literal['context', 'added', 'removed', 'modified']
|
||||||
|
left_number: int | None = None
|
||||||
|
right_number: int | None = None
|
||||||
|
left_text: str = ''
|
||||||
|
right_text: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
class BackupDiffStats(BaseModel):
|
||||||
|
added: int = 0
|
||||||
|
removed: int = 0
|
||||||
|
modified: int = 0
|
||||||
|
context: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class BackupDiffResponse(BaseModel):
|
||||||
|
left_backup_id: int
|
||||||
|
right_backup_id: int
|
||||||
|
left_file_name: str | None = None
|
||||||
|
right_file_name: str | None = None
|
||||||
|
diff_text: str
|
||||||
|
diff_html: str | None = None
|
||||||
|
stats: BackupDiffStats | None = None
|
||||||
|
lines: list[BackupDiffLine] = []
|
||||||
|
|
||||||
|
|
||||||
|
class BulkActionRequest(BaseModel):
|
||||||
|
action: Literal['download', 'delete']
|
||||||
|
backup_ids: list[int]
|
||||||
28
backend/app/schemas/dashboard.py
Normal file
28
backend/app/schemas/dashboard.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class StorageStats(BaseModel):
|
||||||
|
total: int
|
||||||
|
used: int
|
||||||
|
free: int
|
||||||
|
folder_used: int
|
||||||
|
usage_percent: float
|
||||||
|
|
||||||
|
|
||||||
|
class OperationLogResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
message: str
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardResponse(BaseModel):
|
||||||
|
routers_count: int
|
||||||
|
export_count: int
|
||||||
|
binary_count: int
|
||||||
|
total_backups: int
|
||||||
|
storage: StorageStats
|
||||||
|
recent_logs: list[OperationLogResponse]
|
||||||
104
backend/app/schemas/router.py
Normal file
104
backend/app/schemas/router.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
ALLOWED_NAME_REGEX = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
DeviceType = Literal["routeros", "switchos"]
|
||||||
|
|
||||||
|
|
||||||
|
class RouterBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
device_type: DeviceType = "routeros"
|
||||||
|
host: str = Field(min_length=1, max_length=255)
|
||||||
|
port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
ssh_user: str | None = Field(default=None, max_length=120)
|
||||||
|
ssh_key: str | None = None
|
||||||
|
ssh_password: str | None = None
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, value: str) -> str:
|
||||||
|
if not ALLOWED_NAME_REGEX.match(value):
|
||||||
|
raise ValueError("Only letters, digits, dashes and underscores are allowed")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("host", "ssh_user", "ssh_key", "ssh_password", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_text(cls, value: str | None) -> str | None:
|
||||||
|
normalized = (value or "").strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def apply_device_defaults(self):
|
||||||
|
if self.device_type == "routeros":
|
||||||
|
self.port = self.port or 22
|
||||||
|
self.ssh_user = self.ssh_user or "admin"
|
||||||
|
return self
|
||||||
|
|
||||||
|
self.port = self.port or 80
|
||||||
|
self.ssh_key = None
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RouterCreate(RouterBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RouterUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
device_type: DeviceType | None = None
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
ssh_user: str | None = None
|
||||||
|
ssh_key: str | None = None
|
||||||
|
ssh_password: str | None = None
|
||||||
|
|
||||||
|
@field_validator("name", "host", "ssh_user", "ssh_key", "ssh_password", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_text(cls, value: str | None) -> str | None:
|
||||||
|
normalized = (value or "").strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
class RouterResponse(RouterBase):
|
||||||
|
id: int
|
||||||
|
owner_id: int
|
||||||
|
effective_username: str | None = None
|
||||||
|
uses_global_ssh_key: bool = False
|
||||||
|
has_effective_ssh_key: bool = False
|
||||||
|
uses_global_switchos_credentials: bool = False
|
||||||
|
has_effective_password: bool = False
|
||||||
|
supports_export: bool = False
|
||||||
|
supports_restore_upload: bool = False
|
||||||
|
last_connection_status: bool | None = None
|
||||||
|
last_connection_tested_at: datetime | None = None
|
||||||
|
last_connection_error: str | None = None
|
||||||
|
last_connection_hostname: str | None = None
|
||||||
|
last_connection_model: str | None = None
|
||||||
|
last_connection_version: str | None = None
|
||||||
|
last_connection_uptime: str | None = None
|
||||||
|
last_connection_transport: str | None = None
|
||||||
|
last_connection_server: str | None = None
|
||||||
|
last_connection_auth_mode: str | None = None
|
||||||
|
last_connection_http_status: str | None = None
|
||||||
|
last_connection_backup_available: bool | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class RouterTestConnection(BaseModel):
|
||||||
|
success: bool
|
||||||
|
tested_at: datetime
|
||||||
|
model: str
|
||||||
|
uptime: str
|
||||||
|
hostname: str
|
||||||
|
version: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
transport: str | None = None
|
||||||
|
server: str | None = None
|
||||||
|
auth_mode: str | None = None
|
||||||
|
http_status: str | None = None
|
||||||
|
backup_available: bool | None = None
|
||||||
88
backend/app/schemas/settings.py
Normal file
88
backend/app/schemas/settings.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.config import settings as app_settings
|
||||||
|
from app.core.cron_utils import CronValidationError, validate_cron_expression
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsBase(BaseModel):
|
||||||
|
backup_retention_days: int = 7
|
||||||
|
log_retention_days: int = 7
|
||||||
|
export_cron: str = ''
|
||||||
|
binary_cron: str = ''
|
||||||
|
retention_cron: str = ''
|
||||||
|
enable_auto_export: bool = False
|
||||||
|
connection_test_interval_minutes: int = Field(default=0, ge=0, le=1440)
|
||||||
|
global_ssh_key: str | None = None
|
||||||
|
default_switchos_username: str | None = None
|
||||||
|
default_switchos_password: str | None = None
|
||||||
|
pushover_token: str | None = None
|
||||||
|
pushover_userkey: str | None = None
|
||||||
|
notify_failures_only: bool = True
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_login: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
smtp_notifications_enabled: bool = False
|
||||||
|
recipient_email: EmailStr | None = None
|
||||||
|
|
||||||
|
@field_validator('export_cron', 'binary_cron', 'retention_cron', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_cron(cls, value: str | None) -> str:
|
||||||
|
return (value or '').strip()
|
||||||
|
|
||||||
|
@field_validator('global_ssh_key', 'default_switchos_username', 'default_switchos_password', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_secret_text(cls, value: str | None) -> str | None:
|
||||||
|
normalized = (value or '').strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
@field_validator('export_cron', 'binary_cron', 'retention_cron')
|
||||||
|
@classmethod
|
||||||
|
def validate_cron(cls, value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
validate_cron_expression(value, app_settings.timezone)
|
||||||
|
except CronValidationError as exc:
|
||||||
|
raise ValueError(f'Invalid cron expression: {exc}') from exc
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdate(SettingsBase):
|
||||||
|
clear_global_ssh_key: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsResponse(SettingsBase):
|
||||||
|
id: int
|
||||||
|
has_global_ssh_key: bool = False
|
||||||
|
has_default_switchos_credentials: bool = False
|
||||||
|
|
||||||
|
model_config = {'from_attributes': True}
|
||||||
|
|
||||||
|
|
||||||
|
class RevealSshKeyRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RevealSshKeyResponse(BaseModel):
|
||||||
|
global_ssh_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerJobStatus(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
enabled: bool
|
||||||
|
cron: str | None = None
|
||||||
|
description: str
|
||||||
|
description_params: dict[str, str | int] | None = None
|
||||||
|
valid: bool
|
||||||
|
next_runs: list[datetime] = []
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerStatusResponse(BaseModel):
|
||||||
|
timezone: str
|
||||||
|
running: bool
|
||||||
|
jobs: list[SchedulerJobStatus]
|
||||||
33
backend/app/schemas/swos_beta.py
Normal file
33
backend/app/schemas/swos_beta.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class SwosBetaCredentials(BaseModel):
|
||||||
|
host: str = Field(min_length=1, max_length=255)
|
||||||
|
port: int = Field(default=80, ge=1, le=65535)
|
||||||
|
username: str = Field(default='admin', min_length=1, max_length=120)
|
||||||
|
password: str = Field(default='', max_length=255)
|
||||||
|
label: str | None = Field(default=None, max_length=120)
|
||||||
|
|
||||||
|
@field_validator('host', 'username', 'password', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_text(cls, value: str | None) -> str:
|
||||||
|
return (value or '').strip()
|
||||||
|
|
||||||
|
@field_validator('label', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_label(cls, value: str | None) -> str | None:
|
||||||
|
normalized = (value or '').strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
class SwosBetaProbeResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
base_url: str
|
||||||
|
status_code: int
|
||||||
|
auth_mode: str
|
||||||
|
page_title: str | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
server: str | None = None
|
||||||
|
save_backup_visible: bool = False
|
||||||
|
backup_endpoint_ok: bool = False
|
||||||
|
note: str | None = None
|
||||||
350
backend/app/services/backup_service.py
Normal file
350
backend/app/services/backup_service.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import difflib
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from app.models.backup import Backup
|
||||||
|
from app.models.router import Router
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.file_service import compute_checksum, ensure_data_dir
|
||||||
|
from app.services.log_service import log_service
|
||||||
|
from app.services.notification_service import notification_service
|
||||||
|
from app.services.router_service import router_service
|
||||||
|
from app.services.settings_service import settings_service
|
||||||
|
|
||||||
|
|
||||||
|
class BackupService:
|
||||||
|
def _device_label(self, router: Router) -> str:
|
||||||
|
platform = 'SwitchOS' if router.device_type == 'switchos' else 'RouterOS'
|
||||||
|
return f'{platform} device {router.name}'
|
||||||
|
|
||||||
|
def _router_for_user(self, db: Session, user: User, router_id: int) -> Router:
|
||||||
|
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == user.id).first()
|
||||||
|
if not router:
|
||||||
|
raise HTTPException(status_code=404, detail='Device not found')
|
||||||
|
return router
|
||||||
|
|
||||||
|
def _serialize_backup(self, backup: Backup):
|
||||||
|
file_path = Path(backup.file_path)
|
||||||
|
return {
|
||||||
|
'id': backup.id,
|
||||||
|
'router_id': backup.router_id,
|
||||||
|
'router_name': backup.router.name if backup.router else None,
|
||||||
|
'device_type': backup.router.device_type if backup.router else 'routeros',
|
||||||
|
'file_path': backup.file_path,
|
||||||
|
'file_name': backup.file_name,
|
||||||
|
'backup_type': backup.backup_type,
|
||||||
|
'checksum': backup.checksum,
|
||||||
|
'file_size': file_path.stat().st_size if file_path.exists() else None,
|
||||||
|
'created_at': backup.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_structured_diff(self, left_lines: list[str], right_lines: list[str]):
|
||||||
|
matcher = difflib.SequenceMatcher(a=left_lines, b=right_lines)
|
||||||
|
rows = []
|
||||||
|
stats = {'added': 0, 'removed': 0, 'modified': 0, 'context': 0}
|
||||||
|
left_number = 1
|
||||||
|
right_number = 1
|
||||||
|
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
for left_text, right_text in zip(left_lines[i1:i2], right_lines[j1:j2]):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
'type': 'context',
|
||||||
|
'left_number': left_number,
|
||||||
|
'right_number': right_number,
|
||||||
|
'left_text': left_text,
|
||||||
|
'right_text': right_text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stats['context'] += 1
|
||||||
|
left_number += 1
|
||||||
|
right_number += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tag == 'delete':
|
||||||
|
for left_text in left_lines[i1:i2]:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
'type': 'removed',
|
||||||
|
'left_number': left_number,
|
||||||
|
'right_number': None,
|
||||||
|
'left_text': left_text,
|
||||||
|
'right_text': '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stats['removed'] += 1
|
||||||
|
left_number += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tag == 'insert':
|
||||||
|
for right_text in right_lines[j1:j2]:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
'type': 'added',
|
||||||
|
'left_number': None,
|
||||||
|
'right_number': right_number,
|
||||||
|
'left_text': '',
|
||||||
|
'right_text': right_text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stats['added'] += 1
|
||||||
|
right_number += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
block_left = left_lines[i1:i2]
|
||||||
|
block_right = right_lines[j1:j2]
|
||||||
|
block_size = max(len(block_left), len(block_right))
|
||||||
|
for index in range(block_size):
|
||||||
|
left_text = block_left[index] if index < len(block_left) else ''
|
||||||
|
right_text = block_right[index] if index < len(block_right) else ''
|
||||||
|
row_type = 'modified'
|
||||||
|
if left_text and not right_text:
|
||||||
|
row_type = 'removed'
|
||||||
|
stats['removed'] += 1
|
||||||
|
elif right_text and not left_text:
|
||||||
|
row_type = 'added'
|
||||||
|
stats['added'] += 1
|
||||||
|
else:
|
||||||
|
stats['modified'] += 1
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
'type': row_type,
|
||||||
|
'left_number': left_number if left_text else None,
|
||||||
|
'right_number': right_number if right_text else None,
|
||||||
|
'left_text': left_text,
|
||||||
|
'right_text': right_text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if left_text:
|
||||||
|
left_number += 1
|
||||||
|
if right_text:
|
||||||
|
right_number += 1
|
||||||
|
|
||||||
|
return rows, stats
|
||||||
|
|
||||||
|
def get_backup_for_user(self, db: Session, user: User, backup_id: int) -> Backup:
|
||||||
|
backup = (
|
||||||
|
db.query(Backup)
|
||||||
|
.options(joinedload(Backup.router))
|
||||||
|
.join(Router)
|
||||||
|
.filter(Backup.id == backup_id, Router.owner_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not backup:
|
||||||
|
raise HTTPException(status_code=404, detail='Backup not found')
|
||||||
|
return backup
|
||||||
|
|
||||||
|
def list_backups(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
search: str | None = None,
|
||||||
|
backup_type: str | None = None,
|
||||||
|
router_id: int | None = None,
|
||||||
|
created_on: date | None = None,
|
||||||
|
sort_by: str = 'created_at',
|
||||||
|
order: str = 'desc',
|
||||||
|
):
|
||||||
|
query = db.query(Backup).options(joinedload(Backup.router)).join(Router).filter(Router.owner_id == user.id)
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
Backup.file_name.ilike(f'%{search}%')
|
||||||
|
| Router.name.ilike(f'%{search}%')
|
||||||
|
| Router.host.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
if backup_type:
|
||||||
|
query = query.filter(Backup.backup_type == backup_type)
|
||||||
|
if router_id:
|
||||||
|
query = query.filter(Backup.router_id == router_id)
|
||||||
|
if created_on:
|
||||||
|
day_start = datetime.combine(created_on, time.min)
|
||||||
|
next_day = day_start + timedelta(days=1)
|
||||||
|
query = query.filter(Backup.created_at >= day_start, Backup.created_at < next_day)
|
||||||
|
|
||||||
|
sort_map = {
|
||||||
|
'created_at': Backup.created_at,
|
||||||
|
'file_name': Backup.file_name,
|
||||||
|
'backup_type': Backup.backup_type,
|
||||||
|
'router_name': Router.name,
|
||||||
|
}
|
||||||
|
sort_column = sort_map.get(sort_by, Backup.created_at)
|
||||||
|
query = query.order_by(sort_column.asc() if order == 'asc' else sort_column.desc())
|
||||||
|
return [self._serialize_backup(backup) for backup in query.all()]
|
||||||
|
|
||||||
|
def list_router_backups(self, db: Session, user: User, router_id: int):
|
||||||
|
router = self._router_for_user(db, user, router_id)
|
||||||
|
backups = (
|
||||||
|
db.query(Backup)
|
||||||
|
.options(joinedload(Backup.router))
|
||||||
|
.filter(Backup.router_id == router.id)
|
||||||
|
.order_by(Backup.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [self._serialize_backup(backup) for backup in backups]
|
||||||
|
|
||||||
|
def export_router(self, db: Session, user: User, router_id: int) -> Backup:
|
||||||
|
router = self._router_for_user(db, user, router_id)
|
||||||
|
if router.device_type != 'routeros':
|
||||||
|
raise HTTPException(status_code=400, detail='Text export is available only for RouterOS devices')
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
name = f'{router.name}_{router.id}_{stamp}.rsc'
|
||||||
|
file_path = ensure_data_dir() / name
|
||||||
|
try:
|
||||||
|
content = router_service.export(router, settings.global_ssh_key)
|
||||||
|
file_path.write_text(content, encoding='utf-8')
|
||||||
|
backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='export')
|
||||||
|
db.add(backup)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(backup)
|
||||||
|
log_service.add(db, f'Export OK for device {router.name}')
|
||||||
|
notification_service.notify(settings, f'Export {router.name} OK', True)
|
||||||
|
return backup
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
notification_service.notify(settings, f'Export {router.name} FAIL: {exc}', False)
|
||||||
|
log_service.add(db, f'Export FAILED for device {router.name}: {exc}')
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
def binary_backup(self, db: Session, user: User, router_id: int) -> Backup:
|
||||||
|
router = self._router_for_user(db, user, router_id)
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
base_name = f'{router.name}_{router.id}_{stamp}'
|
||||||
|
extension = '.swb' if router.device_type == 'switchos' else '.backup'
|
||||||
|
name = f'{base_name}{extension}'
|
||||||
|
file_path = ensure_data_dir() / name
|
||||||
|
try:
|
||||||
|
router_service.binary_backup(router, base_name, str(file_path), settings.global_ssh_key, settings)
|
||||||
|
checksum = compute_checksum(str(file_path))
|
||||||
|
backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='binary', checksum=checksum)
|
||||||
|
db.add(backup)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(backup)
|
||||||
|
log_service.add(db, f'Binary backup OK for {self._device_label(router)}')
|
||||||
|
notification_service.notify(settings, f'Backup {router.name} OK', True)
|
||||||
|
return backup
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
notification_service.notify(settings, f'Backup {router.name} FAIL: {exc}', False)
|
||||||
|
log_service.add(db, f'Binary backup FAILED for {self._device_label(router)}: {exc}')
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
def upload_backup_to_router(self, db: Session, user: User, router_id: int, backup_id: int):
|
||||||
|
router = self._router_for_user(db, user, router_id)
|
||||||
|
if router.device_type != 'routeros':
|
||||||
|
raise HTTPException(status_code=400, detail='Restore upload is available only for RouterOS devices')
|
||||||
|
backup = self.get_backup_for_user(db, user, backup_id)
|
||||||
|
if backup.backup_type != 'binary':
|
||||||
|
raise HTTPException(status_code=400, detail='Only binary backups can be uploaded')
|
||||||
|
if backup.router and backup.router.device_type != 'routeros':
|
||||||
|
raise HTTPException(status_code=400, detail='SwitchOS backup files cannot be restored over SSH upload')
|
||||||
|
checksum = compute_checksum(backup.file_path)
|
||||||
|
if backup.checksum and checksum != backup.checksum:
|
||||||
|
raise HTTPException(status_code=400, detail='Checksum mismatch')
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
router_service.upload_backup(router, backup.file_path, settings.global_ssh_key)
|
||||||
|
log_service.add(db, f'Upload backup OK for device {router.name}')
|
||||||
|
|
||||||
|
def delete_backup(self, db: Session, user: User, backup_id: int, commit: bool = True):
|
||||||
|
backup = self.get_backup_for_user(db, user, backup_id)
|
||||||
|
path = Path(backup.file_path)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
db.delete(backup)
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def diff_backups(self, db: Session, user: User, left_id: int, right_id: int):
|
||||||
|
left = self.get_backup_for_user(db, user, left_id)
|
||||||
|
right = self.get_backup_for_user(db, user, right_id)
|
||||||
|
if left.backup_type != 'export' or right.backup_type != 'export':
|
||||||
|
raise HTTPException(status_code=400, detail='Diff is supported only for export backups')
|
||||||
|
left_lines = Path(left.file_path).read_text(encoding='utf-8', errors='ignore').splitlines()
|
||||||
|
right_lines = Path(right.file_path).read_text(encoding='utf-8', errors='ignore').splitlines()
|
||||||
|
diff_lines = list(
|
||||||
|
difflib.unified_diff(left_lines, right_lines, fromfile=left.file_name, tofile=right.file_name, lineterm='')
|
||||||
|
)
|
||||||
|
diff_html = difflib.HtmlDiff(wrapcolumn=120).make_file(
|
||||||
|
left_lines,
|
||||||
|
right_lines,
|
||||||
|
fromdesc=left.file_name,
|
||||||
|
todesc=right.file_name,
|
||||||
|
context=True,
|
||||||
|
numlines=2,
|
||||||
|
)
|
||||||
|
structured_lines, stats = self._build_structured_diff(left_lines, right_lines)
|
||||||
|
return {
|
||||||
|
'left_backup_id': left_id,
|
||||||
|
'right_backup_id': right_id,
|
||||||
|
'left_file_name': left.file_name,
|
||||||
|
'right_file_name': right.file_name,
|
||||||
|
'diff_text': '\n'.join(diff_lines),
|
||||||
|
'diff_html': diff_html,
|
||||||
|
'stats': stats,
|
||||||
|
'lines': structured_lines,
|
||||||
|
}
|
||||||
|
|
||||||
|
def email_backup(self, db: Session, user: User, backup_id: int):
|
||||||
|
backup = self.get_backup_for_user(db, user, backup_id)
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
platform_name = 'SwitchOS' if backup.router and backup.router.device_type == 'switchos' else 'RouterOS'
|
||||||
|
noun = 'Export' if backup.backup_type == 'export' else 'Backup'
|
||||||
|
subject = f'{platform_name} {noun}: {backup.file_name}'
|
||||||
|
body = f'Sending {backup.file_name} from device {backup.router.name}.'
|
||||||
|
notification_service.send_email(settings, subject, body, backup.file_path)
|
||||||
|
log_service.add(db, f'Email sent for backup {backup.file_name}')
|
||||||
|
|
||||||
|
def export_all(self, db: Session, user: User):
|
||||||
|
routers = db.query(Router).filter(Router.owner_id == user.id).all()
|
||||||
|
result = []
|
||||||
|
for router in routers:
|
||||||
|
if (router.device_type or 'routeros').lower() != 'routeros':
|
||||||
|
result.append({
|
||||||
|
'router': router.name,
|
||||||
|
'status': 'skipped',
|
||||||
|
'message': 'Text export is available only for RouterOS devices',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
backup = self.export_router(db, user, router.id)
|
||||||
|
result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id})
|
||||||
|
except Exception as exc:
|
||||||
|
result.append({'router': router.name, 'status': 'error', 'message': str(exc)})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def binary_all(self, db: Session, user: User):
|
||||||
|
routers = db.query(Router).filter(Router.owner_id == user.id).all()
|
||||||
|
result = []
|
||||||
|
for router in routers:
|
||||||
|
try:
|
||||||
|
backup = self.binary_backup(db, user, router.id)
|
||||||
|
result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id})
|
||||||
|
except Exception as exc:
|
||||||
|
result.append({'router': router.name, 'status': 'error', 'message': str(exc)})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cleanup_old_backups(self, db: Session):
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=settings.backup_retention_days)
|
||||||
|
old_backups = db.query(Backup).filter(Backup.created_at < cutoff).all()
|
||||||
|
deleted_count = 0
|
||||||
|
for backup in old_backups:
|
||||||
|
path = Path(backup.file_path)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
db.delete(backup)
|
||||||
|
deleted_count += 1
|
||||||
|
db.commit()
|
||||||
|
log_service.add(db, f'Retention cleanup removed {deleted_count} backups older than {settings.backup_retention_days} days')
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
backup_service = BackupService()
|
||||||
38
backend/app/services/file_service.py
Normal file
38
backend/app/services/file_service.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.schemas.dashboard import StorageStats
|
||||||
|
|
||||||
|
|
||||||
|
def compute_checksum(file_path: str) -> str:
|
||||||
|
sha256 = hashlib.sha256()
|
||||||
|
with open(file_path, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(4096), b""):
|
||||||
|
sha256.update(chunk)
|
||||||
|
return sha256.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_data_dir() -> Path:
|
||||||
|
return settings.data_path
|
||||||
|
|
||||||
|
|
||||||
|
def get_folder_size() -> int:
|
||||||
|
total = 0
|
||||||
|
for dirpath, _, filenames in os.walk(ensure_data_dir()):
|
||||||
|
for filename in filenames:
|
||||||
|
try:
|
||||||
|
total += os.path.getsize(Path(dirpath) / filename)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def get_storage_stats() -> StorageStats:
|
||||||
|
ensure_data_dir()
|
||||||
|
disk = shutil.disk_usage(ensure_data_dir())
|
||||||
|
folder_used = get_folder_size()
|
||||||
|
usage_percent = (folder_used / disk.total) * 100 if disk.total else 0
|
||||||
|
return StorageStats(total=disk.total, used=disk.used, free=disk.free, folder_used=folder_used, usage_percent=usage_percent)
|
||||||
24
backend/app/services/log_service.py
Normal file
24
backend/app/services/log_service.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.log import OperationLog
|
||||||
|
|
||||||
|
|
||||||
|
class LogService:
|
||||||
|
def add(self, db: Session, message: str, commit: bool = True) -> None:
|
||||||
|
db.add(OperationLog(message=message))
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def delete_older_than(self, db: Session, days: int) -> int:
|
||||||
|
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
||||||
|
logs = db.query(OperationLog).filter(OperationLog.timestamp < cutoff).all()
|
||||||
|
count = len(logs)
|
||||||
|
for log in logs:
|
||||||
|
db.delete(log)
|
||||||
|
db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
log_service = LogService()
|
||||||
78
backend/app/services/notification_service.py
Normal file
78
backend/app/services/notification_service.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import smtplib
|
||||||
|
from email import encoders
|
||||||
|
from email.mime.base import MIMEBase
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.core.config import settings as app_settings
|
||||||
|
from app.models.settings import GlobalSettings
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationService:
|
||||||
|
def send_pushover(self, token: str, user_key: str, message: str, title: str = "RouterOS Backup") -> bool:
|
||||||
|
response = requests.post(
|
||||||
|
"https://api.pushover.net/1/messages.json",
|
||||||
|
data={"token": token, "user": user_key, "message": message, "title": title},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
return response.ok
|
||||||
|
|
||||||
|
def send_email(self, settings: GlobalSettings, subject: str, body: str, attachment_path: str | None = None):
|
||||||
|
if not (settings.smtp_host and settings.smtp_login and settings.smtp_password):
|
||||||
|
raise ValueError("SMTP is not configured")
|
||||||
|
recipient = (settings.recipient_email or settings.smtp_login or "").strip()
|
||||||
|
if not recipient:
|
||||||
|
raise ValueError("Recipient email is empty")
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["From"] = settings.smtp_login
|
||||||
|
msg["To"] = recipient
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
|
||||||
|
if attachment_path:
|
||||||
|
attachment = Path(attachment_path)
|
||||||
|
with attachment.open("rb") as handle:
|
||||||
|
part = MIMEBase("application", "octet-stream")
|
||||||
|
part.set_payload(handle.read())
|
||||||
|
encoders.encode_base64(part)
|
||||||
|
part.add_header("Content-Disposition", f'attachment; filename="{attachment.name}"')
|
||||||
|
msg.attach(part)
|
||||||
|
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=app_settings.smtp_timeout_seconds) as server:
|
||||||
|
if app_settings.smtp_starttls:
|
||||||
|
server.starttls()
|
||||||
|
server.login(settings.smtp_login, settings.smtp_password)
|
||||||
|
server.sendmail(settings.smtp_login, [recipient], msg.as_string())
|
||||||
|
|
||||||
|
def notify(self, settings: GlobalSettings, message: str, success: bool):
|
||||||
|
if settings.notify_failures_only and success:
|
||||||
|
return
|
||||||
|
if settings.smtp_notifications_enabled:
|
||||||
|
try:
|
||||||
|
self.send_email(settings, "Mikrotik Backup System notification", message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if settings.pushover_token and settings.pushover_userkey:
|
||||||
|
try:
|
||||||
|
self.send_pushover(settings.pushover_token, settings.pushover_userkey, message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send_test_email(self, settings: GlobalSettings):
|
||||||
|
self.send_email(settings, "Mikrotik Backup System test", "This is a test email from Mikrotik Backup System")
|
||||||
|
|
||||||
|
def send_test_pushover(self, settings: GlobalSettings):
|
||||||
|
if not (settings.pushover_token and settings.pushover_userkey):
|
||||||
|
raise ValueError("Pushover is not configured")
|
||||||
|
self.send_pushover(
|
||||||
|
settings.pushover_token,
|
||||||
|
settings.pushover_userkey,
|
||||||
|
"Test pushover from Mikrotik Backup System",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
notification_service = NotificationService()
|
||||||
220
backend/app/services/router_service.py
Normal file
220
backend/app/services/router_service.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.router import Router
|
||||||
|
from app.services.log_service import log_service
|
||||||
|
from app.services.swos_beta_service import swos_beta_service
|
||||||
|
|
||||||
|
|
||||||
|
class RouterService:
|
||||||
|
def _load_pkey(self, ssh_key_str: str):
|
||||||
|
key_str = (ssh_key_str or "").strip()
|
||||||
|
key_buffer = io.StringIO(key_str)
|
||||||
|
loaders = [
|
||||||
|
paramiko.RSAKey.from_private_key,
|
||||||
|
paramiko.Ed25519Key.from_private_key,
|
||||||
|
paramiko.ECDSAKey.from_private_key,
|
||||||
|
]
|
||||||
|
last_error = None
|
||||||
|
for loader in loaders:
|
||||||
|
key_buffer.seek(0)
|
||||||
|
try:
|
||||||
|
return loader(key_buffer)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
raise ValueError("Failed to load SSH private key") from last_error
|
||||||
|
|
||||||
|
def _connect(self, router: Router, global_ssh_key: str | None = None):
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
key_source = router.ssh_key.strip() if router.ssh_key and router.ssh_key.strip() else (global_ssh_key or "")
|
||||||
|
if key_source:
|
||||||
|
pkey = self._load_pkey(key_source)
|
||||||
|
client.connect(router.host, port=router.port, username=router.ssh_user, pkey=pkey, timeout=10)
|
||||||
|
else:
|
||||||
|
client.connect(
|
||||||
|
router.host,
|
||||||
|
port=router.port,
|
||||||
|
username=router.ssh_user,
|
||||||
|
password=router.ssh_password,
|
||||||
|
timeout=10,
|
||||||
|
allow_agent=False,
|
||||||
|
look_for_keys=False,
|
||||||
|
banner_timeout=10,
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
def export(self, router: Router, global_ssh_key: str | None = None) -> str:
|
||||||
|
if router.device_type != 'routeros':
|
||||||
|
raise ValueError('Export tekstowy jest dostępny tylko dla RouterOS.')
|
||||||
|
client = self._connect(router, global_ssh_key)
|
||||||
|
_, stdout, _ = client.exec_command('/export')
|
||||||
|
output = stdout.read().decode('utf-8', errors='ignore')
|
||||||
|
client.close()
|
||||||
|
return output
|
||||||
|
|
||||||
|
def binary_backup(self, router: Router, backup_name: str, local_path: str, global_ssh_key: str | None = None, global_settings=None) -> str:
|
||||||
|
if router.device_type == 'switchos':
|
||||||
|
downloaded = swos_beta_service.download_backup_for_router(router, global_settings)
|
||||||
|
Path(local_path).write_bytes(downloaded.content)
|
||||||
|
return local_path
|
||||||
|
|
||||||
|
client = self._connect(router, global_ssh_key)
|
||||||
|
_, stdout, _ = client.exec_command(f'/system backup save name={backup_name}')
|
||||||
|
stdout.channel.recv_exit_status()
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
remote_file = f'{backup_name}.backup'
|
||||||
|
sftp.get(remote_file, local_path)
|
||||||
|
try:
|
||||||
|
sftp.remove(remote_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sftp.close()
|
||||||
|
client.close()
|
||||||
|
return local_path
|
||||||
|
|
||||||
|
def upload_backup(self, router: Router, local_backup_path: str, global_ssh_key: str | None = None):
|
||||||
|
if router.device_type != 'routeros':
|
||||||
|
raise ValueError('Przywracanie plików jest dostępne tylko dla RouterOS.')
|
||||||
|
client = self._connect(router, global_ssh_key)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
target_name = Path(local_backup_path).name
|
||||||
|
sftp.put(local_backup_path, target_name)
|
||||||
|
sftp.close()
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
def _probe_routeros_connection(self, router: Router, global_ssh_key: str | None = None):
|
||||||
|
tested_at = datetime.utcnow()
|
||||||
|
try:
|
||||||
|
client = self._connect(router, global_ssh_key)
|
||||||
|
_, stdout, _ = client.exec_command('/system resource print without-paging')
|
||||||
|
resource_output = stdout.read().decode('utf-8', errors='ignore')
|
||||||
|
_, stdout, _ = client.exec_command('/system identity print')
|
||||||
|
identity_output = stdout.read().decode('utf-8', errors='ignore')
|
||||||
|
client.close()
|
||||||
|
model = 'Unknown'
|
||||||
|
uptime = 'Unknown'
|
||||||
|
hostname = 'Unknown'
|
||||||
|
version = 'Unknown'
|
||||||
|
for line in resource_output.splitlines():
|
||||||
|
if 'board-name' in line:
|
||||||
|
model = line.split(':', 1)[1].strip()
|
||||||
|
if 'uptime' in line:
|
||||||
|
uptime = line.split(':', 1)[1].strip()
|
||||||
|
if 'version' in line:
|
||||||
|
version = line.split(':', 1)[1].strip()
|
||||||
|
for line in identity_output.splitlines():
|
||||||
|
if 'name' in line:
|
||||||
|
hostname = line.split(':', 1)[1].strip()
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'tested_at': tested_at,
|
||||||
|
'model': model,
|
||||||
|
'uptime': uptime,
|
||||||
|
'hostname': hostname,
|
||||||
|
'version': version,
|
||||||
|
'error': None,
|
||||||
|
'transport': 'ssh',
|
||||||
|
'server': None,
|
||||||
|
'auth_mode': 'ssh',
|
||||||
|
'http_status': None,
|
||||||
|
'backup_available': None,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'tested_at': tested_at,
|
||||||
|
'model': 'Unknown',
|
||||||
|
'uptime': 'Unknown',
|
||||||
|
'hostname': router.name,
|
||||||
|
'version': None,
|
||||||
|
'error': str(exc),
|
||||||
|
'transport': 'ssh',
|
||||||
|
'server': None,
|
||||||
|
'auth_mode': 'ssh',
|
||||||
|
'http_status': None,
|
||||||
|
'backup_available': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def probe_connection(self, router: Router, global_ssh_key: str | None = None, global_settings=None):
|
||||||
|
if router.device_type == 'switchos':
|
||||||
|
return swos_beta_service.probe_router(router, global_settings)
|
||||||
|
return self._probe_routeros_connection(router, global_ssh_key)
|
||||||
|
|
||||||
|
def _store_connection_result(self, db: Session, router: Router, result: dict):
|
||||||
|
router.last_connection_status = result['success']
|
||||||
|
router.last_connection_tested_at = result['tested_at']
|
||||||
|
router.last_connection_error = result.get('error')
|
||||||
|
router.last_connection_hostname = result.get('hostname')
|
||||||
|
router.last_connection_model = result.get('model')
|
||||||
|
router.last_connection_version = result.get('version')
|
||||||
|
router.last_connection_uptime = result.get('uptime')
|
||||||
|
router.last_connection_transport = result.get('transport')
|
||||||
|
router.last_connection_server = result.get('server')
|
||||||
|
router.last_connection_auth_mode = result.get('auth_mode')
|
||||||
|
router.last_connection_http_status = result.get('http_status')
|
||||||
|
router.last_connection_backup_available = result.get('backup_available')
|
||||||
|
db.add(router)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(router)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _device_label(self, router: Router) -> str:
|
||||||
|
platform = 'SwitchOS' if router.device_type == 'switchos' else 'RouterOS'
|
||||||
|
return f'{platform} device {router.name}'
|
||||||
|
|
||||||
|
def _build_connection_log_message(self, router: Router, result: dict) -> str:
|
||||||
|
device_label = self._device_label(router)
|
||||||
|
transport = result.get('transport') or 'unknown transport'
|
||||||
|
auth_mode = result.get('auth_mode')
|
||||||
|
http_status = result.get('http_status')
|
||||||
|
backup_available = result.get('backup_available')
|
||||||
|
hostname = result.get('hostname')
|
||||||
|
model = result.get('model')
|
||||||
|
version = result.get('version')
|
||||||
|
uptime = result.get('uptime')
|
||||||
|
server = result.get('server')
|
||||||
|
|
||||||
|
details = [f'via {transport}', f'target={router.host}:{router.port}']
|
||||||
|
if router.device_type == 'routeros':
|
||||||
|
if router.ssh_user:
|
||||||
|
details.append(f'user={router.ssh_user}')
|
||||||
|
if hostname:
|
||||||
|
details.append(f'hostname={hostname}')
|
||||||
|
if model and model != 'Unknown':
|
||||||
|
details.append(f'model={model}')
|
||||||
|
if version and version != 'Unknown':
|
||||||
|
details.append(f'version={version}')
|
||||||
|
if uptime and uptime != 'Unknown':
|
||||||
|
details.append(f'uptime={uptime}')
|
||||||
|
else:
|
||||||
|
if auth_mode:
|
||||||
|
details.append(f'auth={auth_mode}')
|
||||||
|
if http_status:
|
||||||
|
details.append(f'http={http_status}')
|
||||||
|
if server:
|
||||||
|
details.append(f'server={server}')
|
||||||
|
if backup_available is not None:
|
||||||
|
details.append(f'backup_available={"yes" if backup_available else "no"}')
|
||||||
|
if hostname:
|
||||||
|
details.append(f'hostname={hostname}')
|
||||||
|
|
||||||
|
detail_suffix = f' ({", ".join(details)})' if details else ''
|
||||||
|
if result.get('success'):
|
||||||
|
return f'Connection test OK for {device_label}{detail_suffix}'
|
||||||
|
|
||||||
|
error = result.get('error') or 'Unknown error'
|
||||||
|
return f'Connection test FAILED for {device_label}{detail_suffix}: {error}'
|
||||||
|
|
||||||
|
def test_connection(self, db: Session, router: Router, global_settings):
|
||||||
|
result = self.probe_connection(router, global_settings.global_ssh_key, global_settings)
|
||||||
|
stored_result = self._store_connection_result(db, router, result)
|
||||||
|
log_service.add(db, self._build_connection_log_message(router, stored_result))
|
||||||
|
return stored_result
|
||||||
|
|
||||||
|
|
||||||
|
router_service = RouterService()
|
||||||
249
backend/app/services/scheduler.py
Normal file
249
backend/app/services/scheduler.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
from app.core.config import settings as app_settings
|
||||||
|
from app.core.cron_utils import CronValidationError, describe_cron_expression, parse_cron_expression, preview_next_runs
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.router import Router
|
||||||
|
from app.services.backup_service import backup_service
|
||||||
|
from app.services.log_service import log_service
|
||||||
|
from app.services.router_service import router_service
|
||||||
|
from app.services.settings_service import settings_service
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerService:
|
||||||
|
def __init__(self):
|
||||||
|
self.scheduler = BackgroundScheduler(timezone=app_settings.timezone)
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.started:
|
||||||
|
return
|
||||||
|
self.reschedule()
|
||||||
|
self.scheduler.start()
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
if self.started:
|
||||||
|
self.scheduler.shutdown(wait=False)
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
def _parse_cron(self, expr: str):
|
||||||
|
return parse_cron_expression(expr, app_settings.timezone)
|
||||||
|
|
||||||
|
def validate_cron(self, expr: str):
|
||||||
|
return self._parse_cron(expr)
|
||||||
|
|
||||||
|
def _interval_next_runs(self, minutes: int, count: int = 3):
|
||||||
|
now = datetime.now()
|
||||||
|
return [now + timedelta(minutes=minutes * index) for index in range(1, count + 1)]
|
||||||
|
|
||||||
|
def scheduler_status(self):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
return {
|
||||||
|
'timezone': app_settings.timezone,
|
||||||
|
'running': self.started,
|
||||||
|
'jobs': [
|
||||||
|
self._describe_job(
|
||||||
|
key='auto_export',
|
||||||
|
label='settings.schedulerAutoExportLabel',
|
||||||
|
enabled=settings.enable_auto_export,
|
||||||
|
cron=settings.export_cron,
|
||||||
|
),
|
||||||
|
self._describe_job(
|
||||||
|
key='auto_binary',
|
||||||
|
label='settings.schedulerBinaryLabel',
|
||||||
|
enabled=bool(settings.binary_cron),
|
||||||
|
cron=settings.binary_cron,
|
||||||
|
),
|
||||||
|
self._describe_job(
|
||||||
|
key='retention',
|
||||||
|
label='settings.schedulerRetentionLabel',
|
||||||
|
enabled=bool(settings.retention_cron),
|
||||||
|
cron=settings.retention_cron,
|
||||||
|
),
|
||||||
|
self._describe_interval_job(
|
||||||
|
key='connection_probe',
|
||||||
|
label='settings.schedulerConnectionLabel',
|
||||||
|
minutes=settings.connection_test_interval_minutes,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
'key': 'log_cleanup',
|
||||||
|
'label': 'settings.schedulerLogsLabel',
|
||||||
|
'enabled': True,
|
||||||
|
'cron': None,
|
||||||
|
'description': 'settings.schedulerLogsDescription',
|
||||||
|
'description_params': None,
|
||||||
|
'valid': True,
|
||||||
|
'next_runs': [],
|
||||||
|
'error': None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _describe_job(self, key: str, label: str, enabled: bool, cron: str | None):
|
||||||
|
cron = (cron or '').strip()
|
||||||
|
if not enabled or not cron:
|
||||||
|
return {
|
||||||
|
'key': key,
|
||||||
|
'label': label,
|
||||||
|
'enabled': False,
|
||||||
|
'cron': cron or None,
|
||||||
|
'description': 'settings.scheduleDisabledHint',
|
||||||
|
'description_params': None,
|
||||||
|
'valid': True,
|
||||||
|
'next_runs': [],
|
||||||
|
'error': None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
next_runs = preview_next_runs(cron, app_settings.timezone, count=3)
|
||||||
|
return {
|
||||||
|
'key': key,
|
||||||
|
'label': label,
|
||||||
|
'enabled': True,
|
||||||
|
'cron': cron,
|
||||||
|
'description': 'settings.schedulerCronDescription',
|
||||||
|
'description_params': {'description': describe_cron_expression(cron)},
|
||||||
|
'valid': True,
|
||||||
|
'next_runs': next_runs,
|
||||||
|
'error': None,
|
||||||
|
}
|
||||||
|
except CronValidationError as exc:
|
||||||
|
return {
|
||||||
|
'key': key,
|
||||||
|
'label': label,
|
||||||
|
'enabled': True,
|
||||||
|
'cron': cron,
|
||||||
|
'description': 'settings.schedulerInvalidCron',
|
||||||
|
'description_params': None,
|
||||||
|
'valid': False,
|
||||||
|
'next_runs': [],
|
||||||
|
'error': str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _describe_interval_job(self, key: str, label: str, minutes: int):
|
||||||
|
minutes = int(minutes or 0)
|
||||||
|
if minutes <= 0:
|
||||||
|
return {
|
||||||
|
'key': key,
|
||||||
|
'label': label,
|
||||||
|
'enabled': False,
|
||||||
|
'cron': None,
|
||||||
|
'description': 'settings.connectionTestsDisabledHint',
|
||||||
|
'description_params': None,
|
||||||
|
'valid': True,
|
||||||
|
'next_runs': [],
|
||||||
|
'error': None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'key': key,
|
||||||
|
'label': label,
|
||||||
|
'enabled': True,
|
||||||
|
'cron': None,
|
||||||
|
'description': 'settings.connectionTestsEverySummary',
|
||||||
|
'description_params': {'minutes': minutes},
|
||||||
|
'valid': True,
|
||||||
|
'next_runs': self._interval_next_runs(minutes),
|
||||||
|
'error': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reschedule(self):
|
||||||
|
self.scheduler.remove_all_jobs()
|
||||||
|
with SessionLocal() as db:
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
job_definitions = [
|
||||||
|
('auto_export', settings.enable_auto_export, settings.export_cron, self._run_auto_export, 'auto export'),
|
||||||
|
('auto_binary', bool(settings.binary_cron), settings.binary_cron, self._run_binary_backup, 'binary backup'),
|
||||||
|
('retention', bool(settings.retention_cron), settings.retention_cron, self._run_retention, 'retention cleanup'),
|
||||||
|
]
|
||||||
|
|
||||||
|
pending_logs: list[str] = []
|
||||||
|
for job_id, enabled, cron, callback, label in job_definitions:
|
||||||
|
cron = (cron or '').strip()
|
||||||
|
if not enabled or not cron:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
trigger = self._parse_cron(cron)
|
||||||
|
self.scheduler.add_job(
|
||||||
|
callback,
|
||||||
|
trigger=trigger,
|
||||||
|
id=job_id,
|
||||||
|
replace_existing=True,
|
||||||
|
coalesce=True,
|
||||||
|
max_instances=1,
|
||||||
|
misfire_grace_time=300,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
pending_logs.append(f'Scheduler skipped invalid {label} cron ({cron}): {exc}')
|
||||||
|
|
||||||
|
if int(settings.connection_test_interval_minutes or 0) > 0:
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_connection_probes,
|
||||||
|
trigger='interval',
|
||||||
|
minutes=int(settings.connection_test_interval_minutes),
|
||||||
|
id='connection_probe',
|
||||||
|
replace_existing=True,
|
||||||
|
coalesce=True,
|
||||||
|
max_instances=1,
|
||||||
|
misfire_grace_time=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_log_cleanup,
|
||||||
|
trigger='interval',
|
||||||
|
days=1,
|
||||||
|
id='log_cleanup',
|
||||||
|
replace_existing=True,
|
||||||
|
coalesce=True,
|
||||||
|
max_instances=1,
|
||||||
|
misfire_grace_time=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
for message in pending_logs:
|
||||||
|
log_service.add(db, message, commit=False)
|
||||||
|
if pending_logs:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def _run_auto_export(self):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
routers = db.query(Router).all()
|
||||||
|
for router in routers:
|
||||||
|
try:
|
||||||
|
backup_service.export_router(db, type('U', (), {'id': router.owner_id})(), router.id)
|
||||||
|
except Exception as exc:
|
||||||
|
log_service.add(db, f'Scheduled export failed for {router.name}: {exc}')
|
||||||
|
|
||||||
|
def _run_binary_backup(self):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
routers = db.query(Router).all()
|
||||||
|
for router in routers:
|
||||||
|
try:
|
||||||
|
backup_service.binary_backup(db, type('U', (), {'id': router.owner_id})(), router.id)
|
||||||
|
except Exception as exc:
|
||||||
|
log_service.add(db, f'Scheduled binary backup failed for {router.name}: {exc}')
|
||||||
|
|
||||||
|
def _run_retention(self):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
backup_service.cleanup_old_backups(db)
|
||||||
|
|
||||||
|
def _run_connection_probes(self):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
routers = db.query(Router).all()
|
||||||
|
for router in routers:
|
||||||
|
result = router_service.test_connection(db, router, settings.global_ssh_key)
|
||||||
|
if not result['success']:
|
||||||
|
log_service.add(db, f'Scheduled connection test failed for {router.name}: {result.get("error") or "Unknown error"}')
|
||||||
|
|
||||||
|
def _run_log_cleanup(self):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
settings = settings_service.get_or_create(db)
|
||||||
|
deleted = log_service.delete_older_than(db, settings.log_retention_days)
|
||||||
|
log_service.add(db, f'Log retention cleanup removed {deleted} entries older than {settings.log_retention_days} days')
|
||||||
|
|
||||||
|
|
||||||
|
scheduler_service = SchedulerService()
|
||||||
32
backend/app/services/settings_service.py
Normal file
32
backend/app/services/settings_service.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.settings import GlobalSettings
|
||||||
|
from app.schemas.settings import SettingsUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsService:
|
||||||
|
def get_or_create(self, db: Session) -> GlobalSettings:
|
||||||
|
settings = db.query(GlobalSettings).first()
|
||||||
|
if not settings:
|
||||||
|
settings = GlobalSettings()
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def update(self, db: Session, payload: SettingsUpdate) -> GlobalSettings:
|
||||||
|
settings = self.get_or_create(db)
|
||||||
|
data = payload.model_dump(exclude={'clear_global_ssh_key'})
|
||||||
|
for key, value in data.items():
|
||||||
|
if key == 'global_ssh_key' and value is None and not payload.clear_global_ssh_key:
|
||||||
|
continue
|
||||||
|
setattr(settings, key, value)
|
||||||
|
if payload.clear_global_ssh_key:
|
||||||
|
settings.global_ssh_key = None
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
settings_service = SettingsService()
|
||||||
174
backend/app/services/swos_beta_service.py
Normal file
174
backend/app/services/swos_beta_service.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
|
||||||
|
|
||||||
|
from app.schemas.swos_beta import SwosBetaCredentials, SwosBetaProbeResponse
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadedSwosBackup:
|
||||||
|
filename: str
|
||||||
|
content: bytes
|
||||||
|
content_type: str
|
||||||
|
auth_mode: str
|
||||||
|
base_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class SwosBetaService:
|
||||||
|
timeout_seconds = 12
|
||||||
|
|
||||||
|
def probe(self, payload: SwosBetaCredentials) -> SwosBetaProbeResponse:
|
||||||
|
base_url = self._build_base_url(payload.host, payload.port)
|
||||||
|
response, auth_mode = self._request_with_fallback('GET', base_url, payload)
|
||||||
|
html = response.text if 'text' in (response.headers.get('content-type') or '').lower() else ''
|
||||||
|
title = self._extract_title(html)
|
||||||
|
|
||||||
|
backup_response, _ = self._request_with_fallback('GET', f'{base_url}/backup.swb', payload, allow_text_fallback=False)
|
||||||
|
backup_ok = backup_response.status_code == 200 and len(backup_response.content) > 0
|
||||||
|
|
||||||
|
return SwosBetaProbeResponse(
|
||||||
|
success=response.ok,
|
||||||
|
base_url=base_url,
|
||||||
|
status_code=response.status_code,
|
||||||
|
auth_mode=auth_mode,
|
||||||
|
page_title=title,
|
||||||
|
content_type=response.headers.get('content-type'),
|
||||||
|
server=response.headers.get('server'),
|
||||||
|
save_backup_visible='save backup' in html.lower(),
|
||||||
|
backup_endpoint_ok=backup_ok,
|
||||||
|
note='SwitchOS jest obsługiwany bezpośrednio w liście urządzeń.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def probe_router(self, router, global_settings) -> dict:
|
||||||
|
payload = self.credentials_from_router(router, global_settings)
|
||||||
|
tested_at = datetime.utcnow()
|
||||||
|
try:
|
||||||
|
result = self.probe(payload)
|
||||||
|
return {
|
||||||
|
'success': result.success,
|
||||||
|
'tested_at': tested_at,
|
||||||
|
'model': 'SwitchOS',
|
||||||
|
'uptime': f'HTTP {result.status_code}',
|
||||||
|
'hostname': result.page_title or router.name,
|
||||||
|
'version': None,
|
||||||
|
'error': None,
|
||||||
|
'transport': 'http',
|
||||||
|
'server': result.server,
|
||||||
|
'auth_mode': result.auth_mode,
|
||||||
|
'http_status': str(result.status_code),
|
||||||
|
'backup_available': result.backup_endpoint_ok,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'tested_at': tested_at,
|
||||||
|
'model': 'SwitchOS',
|
||||||
|
'uptime': 'HTTP',
|
||||||
|
'hostname': router.name,
|
||||||
|
'version': None,
|
||||||
|
'error': str(exc),
|
||||||
|
'transport': 'http',
|
||||||
|
'server': None,
|
||||||
|
'auth_mode': None,
|
||||||
|
'http_status': None,
|
||||||
|
'backup_available': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def credentials_from_router(self, router, global_settings) -> SwosBetaCredentials:
|
||||||
|
username = (getattr(router, 'ssh_user', None) or '').strip() or (getattr(global_settings, 'default_switchos_username', None) or '').strip()
|
||||||
|
password = (getattr(router, 'ssh_password', None) or '').strip() or (getattr(global_settings, 'default_switchos_password', None) or '').strip()
|
||||||
|
if not username:
|
||||||
|
raise ValueError('Brak użytkownika SwitchOS. Ustaw dane w urządzeniu albo w ustawieniach globalnych.')
|
||||||
|
return SwosBetaCredentials(
|
||||||
|
host=router.host,
|
||||||
|
port=router.port or 80,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
label=router.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def download_backup(self, payload: SwosBetaCredentials) -> DownloadedSwosBackup:
|
||||||
|
base_url = self._build_base_url(payload.host, payload.port)
|
||||||
|
response, auth_mode = self._request_with_fallback('GET', f'{base_url}/backup.swb', payload, allow_text_fallback=False)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ValueError(f'Urządzenie zwróciło kod HTTP {response.status_code} dla /backup.swb.')
|
||||||
|
if not response.content:
|
||||||
|
raise ValueError('Urządzenie zwróciło pusty plik backupu.')
|
||||||
|
|
||||||
|
filename = self._build_filename(payload)
|
||||||
|
content_type = response.headers.get('content-type') or 'application/octet-stream'
|
||||||
|
return DownloadedSwosBackup(
|
||||||
|
filename=filename,
|
||||||
|
content=response.content,
|
||||||
|
content_type=content_type,
|
||||||
|
auth_mode=auth_mode,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def download_backup_for_router(self, router, global_settings) -> DownloadedSwosBackup:
|
||||||
|
return self.download_backup(self.credentials_from_router(router, global_settings))
|
||||||
|
|
||||||
|
def _request_with_fallback(self, method: str, url: str, payload: SwosBetaCredentials, allow_text_fallback: bool = True):
|
||||||
|
attempts = []
|
||||||
|
auth_variants = [
|
||||||
|
('digest', HTTPDigestAuth(payload.username, payload.password)),
|
||||||
|
('basic', HTTPBasicAuth(payload.username, payload.password)),
|
||||||
|
]
|
||||||
|
if allow_text_fallback:
|
||||||
|
auth_variants.append(('none', None))
|
||||||
|
|
||||||
|
last_response = None
|
||||||
|
for label, auth in auth_variants:
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
auth=auth,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
allow_redirects=True,
|
||||||
|
)
|
||||||
|
last_response = response
|
||||||
|
if response.status_code < 400:
|
||||||
|
return response, label
|
||||||
|
attempts.append(f'{label}:{response.status_code}')
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
attempts.append(f'{label}:{exc.__class__.__name__}')
|
||||||
|
|
||||||
|
if last_response is not None:
|
||||||
|
raise ValueError(f'Nie udało się połączyć ze SwitchOS ({", ".join(attempts)}).')
|
||||||
|
raise ValueError('Nie udało się połączyć ze SwitchOS.')
|
||||||
|
|
||||||
|
def _build_base_url(self, host: str, port: int) -> str:
|
||||||
|
raw = host.strip()
|
||||||
|
parsed = urlparse(raw if '://' in raw else f'http://{raw}')
|
||||||
|
scheme = parsed.scheme or 'http'
|
||||||
|
if scheme not in {'http', 'https'}:
|
||||||
|
raise ValueError('Dozwolone są tylko adresy HTTP lub HTTPS.')
|
||||||
|
if not parsed.hostname:
|
||||||
|
raise ValueError('Nieprawidłowy adres hosta.')
|
||||||
|
resolved_port = parsed.port or port
|
||||||
|
base = f'{scheme}://{parsed.hostname}'
|
||||||
|
if resolved_port not in {80, 443} or (scheme == 'http' and resolved_port != 80) or (scheme == 'https' and resolved_port != 443):
|
||||||
|
base = f'{base}:{resolved_port}'
|
||||||
|
return base.rstrip('/')
|
||||||
|
|
||||||
|
def _extract_title(self, html: str) -> str | None:
|
||||||
|
if not html:
|
||||||
|
return None
|
||||||
|
match = re.search(r'<title>(.*?)</title>', html, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return re.sub(r'\s+', ' ', match.group(1)).strip() or None
|
||||||
|
|
||||||
|
def _build_filename(self, payload: SwosBetaCredentials) -> str:
|
||||||
|
label = payload.label or payload.host
|
||||||
|
safe = re.sub(r'[^A-Za-z0-9._-]+', '-', label).strip('-') or 'switchos'
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||||
|
return f'{safe}-switchos-{timestamp}.swb'
|
||||||
|
|
||||||
|
|
||||||
|
swos_beta_service = SwosBetaService()
|
||||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
fastapi[standard]==0.135.2
|
||||||
|
sqlalchemy==2.0.49
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic-settings==2.13.1
|
||||||
|
python-jose[cryptography]==3.5.0
|
||||||
|
passlib==1.7.4
|
||||||
|
python-multipart==0.0.20
|
||||||
|
paramiko==3.5.1
|
||||||
|
apscheduler==3.11.0
|
||||||
|
requests==2.32.3
|
||||||
|
alembic==1.15.2
|
||||||
|
email-validator==2.2.0
|
||||||
|
pytest==8.3.5
|
||||||
|
httpx==0.28.1
|
||||||
117
backend/scripts/migrate_legacy_sqlite.py
Executable file
117
backend/scripts/migrate_legacy_sqlite.py
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Import data from the original Flask SQLite database into the new schema.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backend/scripts/migrate_legacy_sqlite.py /path/to/backup_routeros.db
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app.core.security import get_password_hash # noqa: E402
|
||||||
|
from app.db.session import SessionLocal, init_db # noqa: E402
|
||||||
|
from app.models.backup import Backup # noqa: E402
|
||||||
|
from app.models.log import OperationLog # noqa: E402
|
||||||
|
from app.models.router import Router # noqa: E402
|
||||||
|
from app.models.settings import GlobalSettings # noqa: E402
|
||||||
|
from app.models.user import User # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dt(value):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value, fmt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python backend/scripts/migrate_legacy_sqlite.py /path/to/legacy.db")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
source_path = Path(sys.argv[1]).resolve()
|
||||||
|
if not source_path.exists():
|
||||||
|
print(f"Legacy DB not found: {source_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
source = sqlite3.connect(str(source_path))
|
||||||
|
source.row_factory = sqlite3.Row
|
||||||
|
dest = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_map: dict[int, int] = {}
|
||||||
|
for row in source.execute("SELECT id, username, password_hash FROM users ORDER BY id"):
|
||||||
|
existing = dest.query(User).filter(User.username == row["username"]).first()
|
||||||
|
if existing:
|
||||||
|
user_map[row["id"]] = existing.id
|
||||||
|
continue
|
||||||
|
user = User(username=row["username"], password_hash=row["password_hash"] or get_password_hash("admin"))
|
||||||
|
dest.add(user)
|
||||||
|
dest.flush()
|
||||||
|
user_map[row["id"]] = user.id
|
||||||
|
|
||||||
|
router_map: dict[int, int] = {}
|
||||||
|
for row in source.execute(
|
||||||
|
"SELECT id, owner_id, name, host, port, ssh_user, ssh_key, ssh_password, created_at FROM routers ORDER BY id"
|
||||||
|
):
|
||||||
|
router = Router(
|
||||||
|
owner_id=user_map.get(row["owner_id"], next(iter(user_map.values()), 1)),
|
||||||
|
name=row["name"],
|
||||||
|
host=row["host"],
|
||||||
|
port=row["port"] or 22,
|
||||||
|
ssh_user=row["ssh_user"] or "admin",
|
||||||
|
ssh_key=row["ssh_key"],
|
||||||
|
ssh_password=row["ssh_password"],
|
||||||
|
created_at=parse_dt(row["created_at"]),
|
||||||
|
)
|
||||||
|
dest.add(router)
|
||||||
|
dest.flush()
|
||||||
|
router_map[row["id"]] = router.id
|
||||||
|
|
||||||
|
for row in source.execute(
|
||||||
|
"SELECT router_id, file_path, backup_type, created_at, checksum FROM backups ORDER BY id"
|
||||||
|
):
|
||||||
|
file_name = Path(row["file_path"] or "backup").name
|
||||||
|
backup = Backup(
|
||||||
|
router_id=router_map[row["router_id"]],
|
||||||
|
file_path=row["file_path"],
|
||||||
|
file_name=file_name,
|
||||||
|
backup_type=row["backup_type"] or "export",
|
||||||
|
created_at=parse_dt(row["created_at"]),
|
||||||
|
checksum=row["checksum"],
|
||||||
|
)
|
||||||
|
dest.add(backup)
|
||||||
|
|
||||||
|
for row in source.execute("SELECT message, timestamp FROM operation_logs ORDER BY id"):
|
||||||
|
dest.add(OperationLog(message=row["message"], timestamp=parse_dt(row["timestamp"])))
|
||||||
|
|
||||||
|
legacy_settings = source.execute("SELECT * FROM global_settings ORDER BY id LIMIT 1").fetchone()
|
||||||
|
if legacy_settings:
|
||||||
|
settings = dest.query(GlobalSettings).first() or GlobalSettings()
|
||||||
|
for key in legacy_settings.keys():
|
||||||
|
if hasattr(settings, key):
|
||||||
|
setattr(settings, key, legacy_settings[key])
|
||||||
|
dest.add(settings)
|
||||||
|
|
||||||
|
dest.commit()
|
||||||
|
print("Migration completed")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
dest.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
35
backend/tests/test_auth.py
Normal file
35
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_accepts_form_and_json(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'auth.db'}")
|
||||||
|
monkeypatch.setenv("DATA_DIR", str(tmp_path / 'data'))
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "test-secret")
|
||||||
|
monkeypatch.setenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||||||
|
monkeypatch.setenv("DEFAULT_ADMIN_PASSWORD", "admin")
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
form_response = client.post("/api/auth/login", data={"username": "admin", "password": "admin"})
|
||||||
|
assert form_response.status_code == 200
|
||||||
|
assert "access_token" in form_response.json()
|
||||||
|
|
||||||
|
json_response = client.post("/api/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
assert json_response.status_code == 200
|
||||||
|
assert "access_token" in json_response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_me(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'me.db'}")
|
||||||
|
monkeypatch.setenv("DATA_DIR", str(tmp_path / 'data'))
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "test-secret")
|
||||||
|
monkeypatch.setenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||||||
|
monkeypatch.setenv("DEFAULT_ADMIN_PASSWORD", "admin")
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
login_response = client.post("/api/auth/login", data={"username": "admin", "password": "admin"})
|
||||||
|
token = login_response.json()["access_token"]
|
||||||
|
me_response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert me_response.status_code == 200
|
||||||
|
assert me_response.json()["username"] == "admin"
|
||||||
10
backend/tests/test_health.py
Normal file
10
backend/tests/test_health.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_endpoint():
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] in {"ok", "error"}
|
||||||
125
backend/tests/test_routeros_logging_and_files_filters.py
Normal file
125
backend/tests/test_routeros_logging_and_files_filters.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.backup import Backup
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: TestClient) -> dict[str, str]:
|
||||||
|
response = client.post('/api/auth/login', data={'username': 'admin', 'password': 'admin'})
|
||||||
|
token = response.json()['access_token']
|
||||||
|
return {'Authorization': f'Bearer {token}'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_routeros_connection_test_creates_verbose_operation_log(monkeypatch):
|
||||||
|
from app.services import router_service as router_service_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
router_service_module.router_service,
|
||||||
|
'probe_connection',
|
||||||
|
lambda router, global_ssh_key=None, global_settings=None: {
|
||||||
|
'success': True,
|
||||||
|
'tested_at': datetime(2026, 4, 13, 10, 30, 0),
|
||||||
|
'model': 'RB5009UG+S+',
|
||||||
|
'uptime': '1d2h',
|
||||||
|
'hostname': 'rb5009-core',
|
||||||
|
'version': '7.18.2',
|
||||||
|
'error': None,
|
||||||
|
'transport': 'ssh',
|
||||||
|
'server': None,
|
||||||
|
'auth_mode': 'ssh',
|
||||||
|
'http_status': None,
|
||||||
|
'backup_available': None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
headers = _login(client)
|
||||||
|
create_response = client.post(
|
||||||
|
'/api/routers',
|
||||||
|
json={
|
||||||
|
'name': 'core01',
|
||||||
|
'device_type': 'routeros',
|
||||||
|
'host': '10.10.10.1',
|
||||||
|
'port': 2222,
|
||||||
|
'ssh_user': 'backup',
|
||||||
|
'ssh_password': 'secret',
|
||||||
|
'ssh_key': None,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
device_id = create_response.json()['id']
|
||||||
|
|
||||||
|
response = client.get(f'/api/routers/{device_id}/test-connection', headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
logs_response = client.get('/api/logs', headers=headers)
|
||||||
|
assert logs_response.status_code == 200
|
||||||
|
assert any(
|
||||||
|
'Connection test OK for RouterOS device core01' in item['message']
|
||||||
|
and 'via ssh' in item['message']
|
||||||
|
and 'target=10.10.10.1:2222' in item['message']
|
||||||
|
and 'user=backup' in item['message']
|
||||||
|
and 'hostname=rb5009-core' in item['message']
|
||||||
|
and 'model=RB5009UG+S+' in item['message']
|
||||||
|
and 'version=7.18.2' in item['message']
|
||||||
|
and 'uptime=1d2h' in item['message']
|
||||||
|
for item in logs_response.json()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_files_endpoint_filters_backups_by_created_on_date(monkeypatch):
|
||||||
|
from app.services import router_service as router_service_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
router_service_module.router_service,
|
||||||
|
'export',
|
||||||
|
lambda router, global_ssh_key=None: f'/system identity set name={router.name}',
|
||||||
|
)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
headers = _login(client)
|
||||||
|
create_response = client.post(
|
||||||
|
'/api/routers',
|
||||||
|
json={
|
||||||
|
'name': 'archive01',
|
||||||
|
'device_type': 'routeros',
|
||||||
|
'host': '10.10.10.2',
|
||||||
|
'port': 22,
|
||||||
|
'ssh_user': 'admin',
|
||||||
|
'ssh_password': 'secret',
|
||||||
|
'ssh_key': None,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
device_id = create_response.json()['id']
|
||||||
|
|
||||||
|
first = client.post(f'/api/backups/router/{device_id}/export', headers=headers)
|
||||||
|
second = client.post(f'/api/backups/router/{device_id}/export', headers=headers)
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert second.status_code == 200
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
first_backup = db.query(Backup).filter(Backup.id == first.json()['id']).first()
|
||||||
|
second_backup = db.query(Backup).filter(Backup.id == second.json()['id']).first()
|
||||||
|
first_backup.created_at = datetime(2026, 4, 12, 9, 15, 0)
|
||||||
|
second_backup.created_at = datetime(2026, 4, 13, 11, 45, 0)
|
||||||
|
db.add(first_backup)
|
||||||
|
db.add(second_backup)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
filtered = client.get(f'/api/backups?router_id={device_id}&created_on=2026-04-13', headers=headers)
|
||||||
|
assert filtered.status_code == 200
|
||||||
|
payload = filtered.json()
|
||||||
|
assert len(payload) == 1
|
||||||
|
assert payload[0]['created_at'].startswith('2026-04-13T11:45:00')
|
||||||
|
|
||||||
|
previous_day = client.get(f'/api/backups?router_id={device_id}&created_on=2026-04-12', headers=headers)
|
||||||
|
assert previous_day.status_code == 200
|
||||||
|
assert len(previous_day.json()) == 1
|
||||||
|
assert previous_day.json()[0]['created_at'].startswith('2026-04-12T09:15:00')
|
||||||
62
backend/tests/test_routers.py
Normal file
62
backend/tests/test_routers.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_list_marks_global_ssh_key_usage(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'routers.db'}")
|
||||||
|
monkeypatch.setenv("DATA_DIR", str(tmp_path / 'data'))
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "test-secret")
|
||||||
|
monkeypatch.setenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||||||
|
monkeypatch.setenv("DEFAULT_ADMIN_PASSWORD", "admin")
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
login_response = client.post("/api/auth/login", data={"username": "admin", "password": "admin"})
|
||||||
|
token = login_response.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
settings_response = client.put(
|
||||||
|
"/api/settings",
|
||||||
|
json={
|
||||||
|
"backup_retention_days": 7,
|
||||||
|
"log_retention_days": 7,
|
||||||
|
"export_cron": "",
|
||||||
|
"binary_cron": "",
|
||||||
|
"retention_cron": "",
|
||||||
|
"enable_auto_export": False,
|
||||||
|
"connection_test_interval_minutes": 0,
|
||||||
|
"global_ssh_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----",
|
||||||
|
"pushover_token": None,
|
||||||
|
"pushover_userkey": None,
|
||||||
|
"notify_failures_only": True,
|
||||||
|
"smtp_host": None,
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_login": None,
|
||||||
|
"smtp_password": None,
|
||||||
|
"smtp_notifications_enabled": False,
|
||||||
|
"recipient_email": None,
|
||||||
|
"clear_global_ssh_key": False
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert settings_response.status_code == 200
|
||||||
|
|
||||||
|
create_response = client.post(
|
||||||
|
"/api/routers",
|
||||||
|
json={
|
||||||
|
"name": "edge01",
|
||||||
|
"host": "10.0.0.1",
|
||||||
|
"port": 22,
|
||||||
|
"ssh_user": "admin",
|
||||||
|
"ssh_password": None,
|
||||||
|
"ssh_key": None
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
|
||||||
|
list_response = client.get("/api/routers", headers=headers)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
payload = list_response.json()
|
||||||
|
assert payload[0]["uses_global_ssh_key"] is True
|
||||||
|
assert payload[0]["has_effective_ssh_key"] is True
|
||||||
24
backend/tests/test_scheduler.py
Normal file
24
backend/tests/test_scheduler.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from app.core.cron_utils import CronValidationError, describe_cron_expression, preview_next_runs, validate_cron_expression
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_cron_expression_accepts_daily_schedule():
|
||||||
|
validate_cron_expression('15 2 * * *', 'Europe/Warsaw')
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_cron_expression_rejects_invalid_schedule():
|
||||||
|
try:
|
||||||
|
validate_cron_expression('bad cron', 'Europe/Warsaw')
|
||||||
|
except CronValidationError:
|
||||||
|
assert True
|
||||||
|
return
|
||||||
|
assert False, 'invalid cron should raise'
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_next_runs_returns_future_datetimes():
|
||||||
|
runs = preview_next_runs('0 3 * * 1', 'Europe/Warsaw', count=2)
|
||||||
|
assert len(runs) == 2
|
||||||
|
assert runs[0] < runs[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_cron_expression_humanizes_common_patterns():
|
||||||
|
assert describe_cron_expression('0 2 * * *') == 'Every day at 02:00'
|
||||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
container_name: routeros-backup-backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./docker-data:/app/storage
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'curl', '-fsS', 'http://localhost:8000/api/health']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
container_name: routeros-backup-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
reverse-proxy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: reverse-proxy/Dockerfile
|
||||||
|
container_name: routeros-backup-reverse-proxy
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
frontend:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- '${APP_PORT:-8080}:80'
|
||||||
|
restart: unless-stopped
|
||||||
9
env.example
Normal file
9
env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
SECRET_KEY=change-me-before-production
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=7899999
|
||||||
|
ALLOW_REGISTRATION=true
|
||||||
|
DEFAULT_ADMIN_USERNAME=admin
|
||||||
|
DEFAULT_ADMIN_PASSWORD=admin
|
||||||
|
APP_PORT=5580
|
||||||
|
API_PREFIX=/api
|
||||||
|
DATA_DIR=/app/storage
|
||||||
|
DATABASE_URL=sqlite:////app/storage/routeros_backup_next.db
|
||||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY frontend/package*.json /app/
|
||||||
|
RUN npm ci || npm install
|
||||||
|
COPY frontend /app
|
||||||
|
RUN npm run build:prod
|
||||||
|
|
||||||
|
FROM nginx:mainline
|
||||||
|
COPY frontend/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist/routeros-backup-manager-next-ui/browser /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
75
frontend/angular.json
Normal file
75
frontend/angular.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"projects": {
|
||||||
|
"mikrotik-backup-system-ui": {
|
||||||
|
"projectType": "application",
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/mikrotik-backup-system-ui",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"node_modules/primeicons/primeicons.css",
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"optimization": true,
|
||||||
|
"outputHashing": "all",
|
||||||
|
"sourceMap": false,
|
||||||
|
"extractLicenses": true,
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "2mb",
|
||||||
|
"maximumError": "3mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "10kb",
|
||||||
|
"maximumError": "20kb"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"extractLicenses": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "mikrotik-backup-system-ui:build"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "mikrotik-backup-system-ui:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "mikrotik-backup-system-ui:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/nginx/default.conf
Normal file
14
frontend/nginx/default.conf
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
etag off;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
15080
frontend/package-lock.json
generated
Normal file
15080
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "mikrotik-backup-system-ui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "ng serve --host 127.0.0.1 --port 4200 --proxy-config proxy.conf.json",
|
||||||
|
"start:lan": "ng serve --host 0.0.0.0 --port 4200 --proxy-config proxy.conf.json",
|
||||||
|
"build": "ng build",
|
||||||
|
"test": "ng test",
|
||||||
|
"build:prod": "ng build --configuration production",
|
||||||
|
"build:dev": "ng build --configuration development"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^20.3.0",
|
||||||
|
"@angular/common": "^20.3.0",
|
||||||
|
"@angular/compiler": "^20.3.0",
|
||||||
|
"@angular/core": "^20.3.0",
|
||||||
|
"@angular/forms": "^20.3.0",
|
||||||
|
"@angular/platform-browser": "^20.3.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^20.3.0",
|
||||||
|
"@angular/router": "^20.3.0",
|
||||||
|
"@ngx-translate/core": "^17.0.0",
|
||||||
|
"@ngx-translate/http-loader": "^17.0.0",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primeng": "^20.1.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"tslib": "^2.8.0",
|
||||||
|
"zone.js": "~0.15.0",
|
||||||
|
"@primeuix/themes": "^1.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^20.3.0",
|
||||||
|
"@angular/cli": "^20.3.0",
|
||||||
|
"@angular/compiler-cli": "^20.3.0",
|
||||||
|
"typescript": "~5.8.0",
|
||||||
|
"ansi-colors": "^4.1.3",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"semver": "^7.7.1",
|
||||||
|
"tree-kill": "^1.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/proxy.conf.json
Normal file
8
frontend/proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://127.0.0.1:8000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"logLevel": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
68
frontend/src/app/app.component.html
Normal file
68
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<p-toast position="top-right"></p-toast>
|
||||||
|
<p-confirmDialog [style]="{ width: '28rem' }"></p-confirmDialog>
|
||||||
|
|
||||||
|
<div class="api-connection-banner" *ngIf="apiSnapshot().state === 'offline'">
|
||||||
|
<div class="api-connection-banner__content">
|
||||||
|
<strong>{{ 'footer.apiOfflineTitle' | translate }}</strong>
|
||||||
|
<span>{{ 'footer.apiOfflineMessage' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="api-connection-banner__action" (click)="refreshApiStatus()">{{ 'footer.retry' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="auth.isLoggedIn(); else authView">
|
||||||
|
<div class="layout-shell" [class.layout-shell--collapsed]="layout.collapsed()">
|
||||||
|
<div class="layout-overlay" [class.is-visible]="layout.mobileOpen()" (click)="layout.closeMobileSidebar()"></div>
|
||||||
|
|
||||||
|
<aside class="layout-sidebar" [class.is-open]="layout.mobileOpen()">
|
||||||
|
<app-sidebar [collapsed]="layout.collapsed()" [items]="menuItems" (navigate)="layout.closeMobileSidebar()"></app-sidebar>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="layout-main">
|
||||||
|
<app-topbar
|
||||||
|
[pageTitle]="currentPageTitle"
|
||||||
|
[username]="auth.user()?.username || 'admin'"
|
||||||
|
[lang]="language.current()"
|
||||||
|
[languages]="languageOptions"
|
||||||
|
[themeMode]="theme.mode()"
|
||||||
|
(menuClick)="layout.toggleSidebar()"
|
||||||
|
(themeClick)="toggleTheme()"
|
||||||
|
(languageChange)="changeLanguage($event)"
|
||||||
|
(logoutClick)="logout()"
|
||||||
|
></app-topbar>
|
||||||
|
|
||||||
|
<main class="layout-content">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="layout-footer">
|
||||||
|
<div class="layout-footer__author">
|
||||||
|
<span class="layout-footer__author-label">{{ 'footer.authorLabel' | translate }}</span>
|
||||||
|
<strong>{{ author }}</strong>
|
||||||
|
<a [href]="authorUrl" target="_blank" rel="noreferrer">{{ authorHandle }}</a>
|
||||||
|
</div>
|
||||||
|
<span class="layout-footer__status" [ngClass]="apiStatusClass">{{ 'footer.apiLabel' | translate }}: {{ apiStateLabelKey() | translate }}</span>
|
||||||
|
<span>{{ 'footer.apiLatencyLabel' | translate }}: {{ apiLatencyLabel() }}</span>
|
||||||
|
<a href="/docs" target="_blank" rel="noreferrer">{{ 'footer.apiDocs' | translate }}</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #authView>
|
||||||
|
<div class="app-auth-view">
|
||||||
|
<main class="app-auth-view__content">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="layout-footer layout-footer--auth">
|
||||||
|
<div class="layout-footer__author">
|
||||||
|
<span class="layout-footer__author-label">{{ 'footer.authorLabel' | translate }}</span>
|
||||||
|
<strong>{{ author }}</strong>
|
||||||
|
<a [href]="authorUrl" target="_blank" rel="noreferrer">{{ authorHandle }}</a>
|
||||||
|
</div>
|
||||||
|
<span class="layout-footer__status" [ngClass]="apiStatusClass">{{ 'footer.apiLabel' | translate }}: {{ apiStateLabelKey() | translate }}</span>
|
||||||
|
<span>{{ 'footer.apiLatencyLabel' | translate }}: {{ apiLatencyLabel() }}</span>
|
||||||
|
<a href="/docs" target="_blank" rel="noreferrer">{{ 'footer.apiDocs' | translate }}</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
141
frontend/src/app/app.component.ts
Normal file
141
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, computed, inject } from '@angular/core';
|
||||||
|
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { filter } from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthService } from './core/services/auth.service';
|
||||||
|
import { FontService } from './core/services/font.service';
|
||||||
|
import { LayoutService } from './core/services/layout.service';
|
||||||
|
import { ThemeService } from './core/services/theme.service';
|
||||||
|
import { APP_LANGUAGE_OPTIONS, AppLanguage, LanguageService } from './core/services/language.service';
|
||||||
|
import { ApiStatusService } from './core/services/api-status.service';
|
||||||
|
import { ToastModule } from 'primeng/toast';
|
||||||
|
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||||
|
|
||||||
|
import { AppSidebarComponent } from './shared/layout/app-sidebar.component';
|
||||||
|
import { AppTopbarComponent, TopbarLanguageOption } from './shared/layout/app-topbar.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterOutlet, TranslateModule, ToastModule, ConfirmDialogModule, AppSidebarComponent, AppTopbarComponent],
|
||||||
|
templateUrl: './app.component.html'
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
auth = inject(AuthService);
|
||||||
|
theme = inject(ThemeService);
|
||||||
|
language = inject(LanguageService);
|
||||||
|
font = inject(FontService);
|
||||||
|
router = inject(Router);
|
||||||
|
layout = inject(LayoutService);
|
||||||
|
apiStatus = inject(ApiStatusService);
|
||||||
|
pageLabel = 'dashboard.title';
|
||||||
|
readonly author = 'Mateusz Gruszczyński';
|
||||||
|
readonly authorHandle = '@linuxiarz.pl';
|
||||||
|
readonly authorUrl = 'https://linuxiarz.pl';
|
||||||
|
readonly apiSnapshot = this.apiStatus.snapshot;
|
||||||
|
readonly languageOptions: TopbarLanguageOption[] = APP_LANGUAGE_OPTIONS;
|
||||||
|
readonly apiStateLabelKey = computed(() => {
|
||||||
|
switch (this.apiSnapshot().state) {
|
||||||
|
case 'online':
|
||||||
|
return 'footer.apiOnline';
|
||||||
|
case 'offline':
|
||||||
|
return 'footer.apiOffline';
|
||||||
|
default:
|
||||||
|
return 'footer.apiChecking';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
readonly apiLatencyLabel = computed(() => {
|
||||||
|
const latency = this.apiSnapshot().latencyMs;
|
||||||
|
return latency === null ? '—' : `${latency} ms`;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly menuItems = [
|
||||||
|
{ label: 'nav.dashboard', link: '/', icon: 'pi pi-home', exact: true },
|
||||||
|
{ label: 'nav.routers', link: '/devices', icon: 'pi pi-server', exact: false },
|
||||||
|
{ label: 'nav.files', link: '/files', icon: 'pi pi-folder-open', exact: false },
|
||||||
|
{ label: 'nav.diffConfigs', link: '/diff-configs', icon: 'pi pi-code', exact: false },
|
||||||
|
{ label: 'nav.logs', link: '/logs', icon: 'pi pi-history', exact: false },
|
||||||
|
{ label: 'nav.changePassword', link: '/change-password', icon: 'pi pi-lock', exact: false },
|
||||||
|
{ label: 'nav.settings', link: '/settings', icon: 'pi pi-cog', exact: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
get currentPageTitle(): string {
|
||||||
|
return this.pageLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.language.init();
|
||||||
|
this.font.init();
|
||||||
|
this.theme.init();
|
||||||
|
this.apiStatus.startMonitoring();
|
||||||
|
this.auth.restoreSession();
|
||||||
|
this.updatePageLabel(this.router.url);
|
||||||
|
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe((event) => {
|
||||||
|
this.updatePageLabel((event as NavigationEnd).urlAfterRedirects);
|
||||||
|
this.layout.closeMobileSidebar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
this.theme.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLanguage(lang: string) {
|
||||||
|
const nextLanguage = lang as AppLanguage;
|
||||||
|
const user = this.auth.user();
|
||||||
|
if (!user) {
|
||||||
|
this.language.set(nextLanguage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.language.setForAuthenticatedUser(nextLanguage);
|
||||||
|
this.auth.updatePreferences({ preferred_language: nextLanguage, preferred_font: user.preferred_font }).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.auth.logout();
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshApiStatus() {
|
||||||
|
this.apiStatus.probe();
|
||||||
|
}
|
||||||
|
|
||||||
|
get apiStatusClass(): string {
|
||||||
|
return `layout-footer__status--${this.apiSnapshot().state}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePageLabel(url: string) {
|
||||||
|
if (url.startsWith('/devices/') || url.startsWith('/routers/')) {
|
||||||
|
this.pageLabel = 'routers.detailTitle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.startsWith('/devices') || url.startsWith('/routers')) {
|
||||||
|
this.pageLabel = 'routers.title';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.startsWith('/files')) {
|
||||||
|
this.pageLabel = 'files.title';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.startsWith('/diff-configs')) {
|
||||||
|
this.pageLabel = 'diffConfigs.title';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.startsWith('/settings')) {
|
||||||
|
this.pageLabel = 'settings.title';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.startsWith('/logs')) {
|
||||||
|
this.pageLabel = 'logs.title';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.startsWith('/change-password')) {
|
||||||
|
this.pageLabel = 'auth.changePassword';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pageLabel = 'dashboard.title';
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/src/app/app.routes.ts
Normal file
29
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { authGuard } from './core/guards/auth.guard';
|
||||||
|
import { ChangePasswordPageComponent } from './features/auth/change-password-page.component';
|
||||||
|
import { LoginPageComponent } from './features/auth/login-page.component';
|
||||||
|
import { RegisterPageComponent } from './features/auth/register-page.component';
|
||||||
|
import { DashboardPageComponent } from './features/dashboard/dashboard-page.component';
|
||||||
|
import { DiffConfigsPageComponent } from './features/diff-configs/diff-configs-page.component';
|
||||||
|
import { FilesPageComponent } from './features/files/files-page.component';
|
||||||
|
import { LogsPageComponent } from './features/logs/logs-page.component';
|
||||||
|
import { RouterDetailPageComponent } from './features/routers/router-detail-page.component';
|
||||||
|
import { RoutersPageComponent } from './features/routers/routers-page.component';
|
||||||
|
import { SettingsPageComponent } from './features/settings/settings-page.component';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: 'login', component: LoginPageComponent },
|
||||||
|
{ path: 'register', component: RegisterPageComponent },
|
||||||
|
{ path: 'change-password', canActivate: [authGuard], component: ChangePasswordPageComponent },
|
||||||
|
{ path: '', canActivate: [authGuard], component: DashboardPageComponent },
|
||||||
|
{ path: 'devices', canActivate: [authGuard], component: RoutersPageComponent },
|
||||||
|
{ path: 'devices/:id', canActivate: [authGuard], component: RouterDetailPageComponent },
|
||||||
|
{ path: 'routers', redirectTo: 'devices', pathMatch: 'full' },
|
||||||
|
{ path: 'routers/:id', redirectTo: 'devices/:id', pathMatch: 'full' },
|
||||||
|
{ path: 'files', canActivate: [authGuard], component: FilesPageComponent },
|
||||||
|
{ path: 'diff-configs', canActivate: [authGuard], component: DiffConfigsPageComponent },
|
||||||
|
{ path: 'settings', canActivate: [authGuard], component: SettingsPageComponent },
|
||||||
|
{ path: 'logs', canActivate: [authGuard], component: LogsPageComponent },
|
||||||
|
{ path: '**', redirectTo: '' }
|
||||||
|
];
|
||||||
14
frontend/src/app/core/guards/auth.guard.ts
Normal file
14
frontend/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
export const authGuard = () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
if (!auth.isLoggedIn()) {
|
||||||
|
router.navigate(['/login']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
15
frontend/src/app/core/guards/guest.guard.ts
Normal file
15
frontend/src/app/core/guards/guest.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
export const guestGuard = () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (auth.isLoggedIn()) {
|
||||||
|
return router.createUrlTree(['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
7
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
7
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
|
||||||
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const token = localStorage.getItem('routeros_token');
|
||||||
|
if (!token) return next(req);
|
||||||
|
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
|
||||||
|
};
|
||||||
83
frontend/src/app/core/services/api-status.service.ts
Normal file
83
frontend/src/app/core/services/api-status.service.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { catchError, fromEvent, interval, merge, of, Subscription, timeout } from 'rxjs';
|
||||||
|
|
||||||
|
type ApiConnectionState = 'checking' | 'online' | 'offline';
|
||||||
|
|
||||||
|
interface HealthResponse {
|
||||||
|
status?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiStatusSnapshot {
|
||||||
|
state: ApiConnectionState;
|
||||||
|
latencyMs: number | null;
|
||||||
|
checkedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ApiStatusService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private started = false;
|
||||||
|
private inFlight = false;
|
||||||
|
private pollSubscription?: Subscription;
|
||||||
|
private offlineSubscription?: Subscription;
|
||||||
|
|
||||||
|
readonly snapshot = signal<ApiStatusSnapshot>({
|
||||||
|
state: 'checking',
|
||||||
|
latencyMs: null,
|
||||||
|
checkedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
startMonitoring() {
|
||||||
|
if (this.started || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
this.pollSubscription = merge(of(0), interval(15000), fromEvent(window, 'focus'), fromEvent(window, 'online')).subscribe(() => {
|
||||||
|
this.probe();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.offlineSubscription = fromEvent(window, 'offline').subscribe(() => {
|
||||||
|
this.snapshot.set({
|
||||||
|
state: 'offline',
|
||||||
|
latencyMs: null,
|
||||||
|
checkedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
probe() {
|
||||||
|
if (this.inFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.inFlight = true;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const params = new HttpParams().set('_', String(Date.now()));
|
||||||
|
|
||||||
|
this.http
|
||||||
|
.get<HealthResponse>('/api/health', { params })
|
||||||
|
.pipe(
|
||||||
|
timeout(4000),
|
||||||
|
catchError(() => of(null))
|
||||||
|
)
|
||||||
|
.subscribe((response) => {
|
||||||
|
const checkedAt = response?.timestamp || new Date().toISOString();
|
||||||
|
if (response?.status === 'ok') {
|
||||||
|
this.snapshot.set({
|
||||||
|
state: 'online',
|
||||||
|
latencyMs: Math.max(1, Math.round(performance.now() - startedAt)),
|
||||||
|
checkedAt
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.snapshot.set({
|
||||||
|
state: 'offline',
|
||||||
|
latencyMs: null,
|
||||||
|
checkedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.inFlight = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/src/app/core/services/api.service.ts
Normal file
9
frontend/src/app/core/services/api.service.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ApiService {
|
||||||
|
readonly baseUrl = '/api';
|
||||||
|
|
||||||
|
constructor(public http: HttpClient) {}
|
||||||
|
}
|
||||||
88
frontend/src/app/core/services/auth.service.ts
Normal file
88
frontend/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { tap } from 'rxjs';
|
||||||
|
|
||||||
|
import { AppFont, FontService } from './font.service';
|
||||||
|
import { AppLanguage, LanguageService } from './language.service';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
preferred_language: AppLanguage;
|
||||||
|
preferred_font: AppFont;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AuthService {
|
||||||
|
private readonly api = '/api';
|
||||||
|
private readonly tokenKey = 'routeros_token';
|
||||||
|
private readonly language = inject(LanguageService);
|
||||||
|
private readonly font = inject(FontService);
|
||||||
|
|
||||||
|
readonly user = signal<AuthUser | null>(null);
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
login(username: string, password: string) {
|
||||||
|
const body = new URLSearchParams({ username, password });
|
||||||
|
return this.http
|
||||||
|
.post<{ access_token: string; user: AuthUser }>(`${this.api}/auth/login`, body.toString(), {
|
||||||
|
headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
localStorage.setItem(this.tokenKey, response.access_token);
|
||||||
|
this.setUserAndApplyPreferences(response.user);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
register(username: string, password: string) {
|
||||||
|
return this.http.post<{ id: number; username: string }>(`${this.api}/auth/register`, { username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
changePassword(current_password: string, new_password: string) {
|
||||||
|
return this.http.post<{ message: string }>(`${this.api}/auth/change-password`, { current_password, new_password });
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreSession() {
|
||||||
|
const token = this.token();
|
||||||
|
if (!token) {
|
||||||
|
this.user.set(null);
|
||||||
|
this.language.resetToGuestPreference();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.http.get<AuthUser>(`${this.api}/auth/me`).subscribe({
|
||||||
|
next: (user) => this.setUserAndApplyPreferences(user),
|
||||||
|
error: () => this.logout()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreferences(preferences: { preferred_language: AppLanguage; preferred_font: AppFont }) {
|
||||||
|
return this.http.put<AuthUser>(`${this.api}/auth/preferences`, preferences).pipe(
|
||||||
|
tap((user) => {
|
||||||
|
this.setUserAndApplyPreferences(user);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
localStorage.removeItem(this.tokenKey);
|
||||||
|
this.user.set(null);
|
||||||
|
this.language.resetToGuestPreference();
|
||||||
|
}
|
||||||
|
|
||||||
|
token() {
|
||||||
|
return localStorage.getItem(this.tokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedIn() {
|
||||||
|
return !!this.token();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setUserAndApplyPreferences(user: AuthUser) {
|
||||||
|
this.user.set(user);
|
||||||
|
this.language.applyForUser(user.preferred_language || 'pl');
|
||||||
|
this.font.set(user.preferred_font || 'default');
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/src/app/core/services/font.service.ts
Normal file
56
frontend/src/app/core/services/font.service.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export type AppFont = 'default' | 'adwaita_mono' | 'hack';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FontService {
|
||||||
|
private readonly key = 'routeros_font';
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly supportedFonts: AppFont[] = ['default', 'adwaita_mono', 'hack'];
|
||||||
|
private readonly fontState = signal<AppFont>('default');
|
||||||
|
|
||||||
|
readonly current = computed(() => this.fontState());
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const stored = localStorage.getItem(this.key) as AppFont | null;
|
||||||
|
const font = stored && this.supportedFonts.includes(stored) ? stored : 'default';
|
||||||
|
this.set(font);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(font: AppFont) {
|
||||||
|
const nextFont = this.supportedFonts.includes(font) ? font : 'default';
|
||||||
|
this.fontState.set(nextFont);
|
||||||
|
localStorage.setItem(this.key, nextFont);
|
||||||
|
this.applyFont(nextFont);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFont(font: AppFont) {
|
||||||
|
const root = this.document.documentElement;
|
||||||
|
const body = this.document.body;
|
||||||
|
const families = this.fontFamilies(font);
|
||||||
|
root.style.setProperty('--font-body', families.body);
|
||||||
|
root.style.setProperty('--font-title', families.title);
|
||||||
|
body.setAttribute('data-app-font', font);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fontFamilies(font: AppFont): { body: string; title: string } {
|
||||||
|
switch (font) {
|
||||||
|
case 'adwaita_mono':
|
||||||
|
return {
|
||||||
|
body: "'Adwaita Mono', 'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace",
|
||||||
|
title: "'Adwaita Mono', 'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace"
|
||||||
|
};
|
||||||
|
case 'hack':
|
||||||
|
return {
|
||||||
|
body: "Hack, 'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace",
|
||||||
|
title: "Hack, 'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace"
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
body: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
title: "'Roboto Mono', 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
frontend/src/app/core/services/language.service.ts
Normal file
69
frontend/src/app/core/services/language.service.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
export type AppLanguage = 'pl' | 'en' | 'es' | 'no';
|
||||||
|
|
||||||
|
export interface AppLanguageOption {
|
||||||
|
code: AppLanguage;
|
||||||
|
label: string;
|
||||||
|
flag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_LANGUAGE_OPTIONS: AppLanguageOption[] = [
|
||||||
|
{ code: 'pl', label: 'Polski', flag: '🇵🇱' },
|
||||||
|
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||||
|
{ code: 'es', label: 'Español', flag: '🇪🇸' },
|
||||||
|
{ code: 'no', label: 'Norsk', flag: '🇳🇴' }
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LanguageService {
|
||||||
|
private readonly key = 'routeros_lang';
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly supportedLanguages = APP_LANGUAGE_OPTIONS.map((option) => option.code);
|
||||||
|
private readonly langState = signal<AppLanguage>('pl');
|
||||||
|
|
||||||
|
readonly current = computed(() => this.langState());
|
||||||
|
readonly options = APP_LANGUAGE_OPTIONS;
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.translate.setDefaultLang('pl');
|
||||||
|
this.apply(this.guestPreference());
|
||||||
|
}
|
||||||
|
|
||||||
|
set(lang: AppLanguage) {
|
||||||
|
const nextLang = this.read(lang) || 'pl';
|
||||||
|
localStorage.setItem(this.key, nextLang);
|
||||||
|
this.apply(nextLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
setForAuthenticatedUser(lang: AppLanguage) {
|
||||||
|
const nextLang = this.read(lang) || 'pl';
|
||||||
|
localStorage.setItem(this.key, nextLang);
|
||||||
|
this.apply(nextLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyForUser(preferredLanguage?: AppLanguage | null) {
|
||||||
|
this.apply(this.read(preferredLanguage || null) || 'pl');
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToGuestPreference() {
|
||||||
|
this.apply(this.guestPreference());
|
||||||
|
}
|
||||||
|
|
||||||
|
private guestPreference(): AppLanguage {
|
||||||
|
return this.read(localStorage.getItem(this.key)) || 'pl';
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(lang: AppLanguage) {
|
||||||
|
this.langState.set(lang);
|
||||||
|
this.translate.use(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
private read(value: string | null | undefined): AppLanguage | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.supportedLanguages.includes(value as AppLanguage) ? (value as AppLanguage) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/src/app/core/services/layout.service.ts
Normal file
22
frontend/src/app/core/services/layout.service.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LayoutService {
|
||||||
|
private readonly collapsedState = signal(false);
|
||||||
|
private readonly mobileOpenState = signal(false);
|
||||||
|
|
||||||
|
readonly collapsed = computed(() => this.collapsedState());
|
||||||
|
readonly mobileOpen = computed(() => this.mobileOpenState());
|
||||||
|
|
||||||
|
toggleSidebar() {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth < 992) {
|
||||||
|
this.mobileOpenState.update((value) => !value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.collapsedState.update((value) => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMobileSidebar() {
|
||||||
|
this.mobileOpenState.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend/src/app/core/services/theme.service.ts
Normal file
41
frontend/src/app/core/services/theme.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ThemeService {
|
||||||
|
private readonly key = 'routeros_theme';
|
||||||
|
private readonly modeState = signal<'light' | 'dark'>('dark');
|
||||||
|
|
||||||
|
readonly mode = computed(() => this.modeState());
|
||||||
|
readonly isDark = computed(() => this.modeState() === 'dark');
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const mode = (localStorage.getItem(this.key) as 'light' | 'dark' | null) || 'dark';
|
||||||
|
this.set(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.set(this.modeState() === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
set(mode: 'light' | 'dark') {
|
||||||
|
this.modeState.set(mode);
|
||||||
|
|
||||||
|
const isDark = mode === 'dark';
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
html.classList.toggle('dark-theme', isDark);
|
||||||
|
body.classList.toggle('dark-theme', isDark);
|
||||||
|
|
||||||
|
html.setAttribute('data-theme', mode);
|
||||||
|
body.setAttribute('data-theme', mode);
|
||||||
|
html.style.colorScheme = isDark ? 'dark' : 'light';
|
||||||
|
body.style.colorScheme = isDark ? 'dark' : 'light';
|
||||||
|
|
||||||
|
localStorage.setItem(this.key, mode);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
81
frontend/src/app/core/services/ui.service.ts
Normal file
81
frontend/src/app/core/services/ui.service.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { ConfirmationService, MessageService } from 'primeng/api';
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
messageKey: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
headerKey?: string;
|
||||||
|
acceptKey?: string;
|
||||||
|
rejectKey?: string;
|
||||||
|
acceptButtonStyleClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UiService {
|
||||||
|
private readonly messageService = inject(MessageService);
|
||||||
|
private readonly confirmationService = inject(ConfirmationService);
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
|
|
||||||
|
success(detailKey: string, params?: Record<string, unknown>) {
|
||||||
|
this.messageService.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: this.t('toast.success'),
|
||||||
|
detail: this.t(detailKey, params)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
info(detailKey: string, params?: Record<string, unknown>) {
|
||||||
|
this.messageService.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: this.t('toast.info'),
|
||||||
|
detail: this.t(detailKey, params)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
error(detailKey: string, params?: Record<string, unknown>) {
|
||||||
|
this.messageService.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: this.t('toast.error'),
|
||||||
|
detail: this.t(detailKey, params)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.messageService.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm(options: ConfirmOptions): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const finish = (value: boolean) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.confirmationService.confirm({
|
||||||
|
header: this.t(options.headerKey ?? 'confirm.header'),
|
||||||
|
message: this.t(options.messageKey, options.params),
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: this.t(options.acceptKey ?? 'common.confirm'),
|
||||||
|
rejectLabel: this.t(options.rejectKey ?? 'common.cancel'),
|
||||||
|
acceptButtonStyleClass: options.acceptButtonStyleClass ?? 'p-button-danger',
|
||||||
|
rejectButtonStyleClass: 'p-button-text',
|
||||||
|
closeOnEscape: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
accept: () => finish(true),
|
||||||
|
reject: () => finish(false)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
instant(key: string, params?: Record<string, unknown>) {
|
||||||
|
return this.t(key, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private t(key: string, params?: Record<string, unknown>): string {
|
||||||
|
return this.translate.instant(key, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
frontend/src/app/core/theme-preset.ts
Normal file
134
frontend/src/app/core/theme-preset.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { definePreset } from '@primeuix/themes';
|
||||||
|
import Lara from '@primeuix/themes/lara';
|
||||||
|
|
||||||
|
const AppPreset = definePreset(Lara, {
|
||||||
|
primitive: {
|
||||||
|
borderRadius: {
|
||||||
|
none: '0',
|
||||||
|
xs: '8px',
|
||||||
|
sm: '10px',
|
||||||
|
md: '12px',
|
||||||
|
lg: '16px',
|
||||||
|
xl: '20px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
semantic: {
|
||||||
|
primary: {
|
||||||
|
50: '#f6eee8',
|
||||||
|
100: '#ecd7c8',
|
||||||
|
200: '#dfb79e',
|
||||||
|
300: '#cf9571',
|
||||||
|
400: '#b9754d',
|
||||||
|
500: '#8d593a',
|
||||||
|
600: '#794a30',
|
||||||
|
700: '#653d28',
|
||||||
|
800: '#533220',
|
||||||
|
900: '#43291a',
|
||||||
|
950: '#2a1910'
|
||||||
|
},
|
||||||
|
colorScheme: {
|
||||||
|
light: {
|
||||||
|
surface: {
|
||||||
|
0: '#ffffff',
|
||||||
|
50: '#f8f8f5',
|
||||||
|
100: '#f1f1ed',
|
||||||
|
200: '#e6e6e0',
|
||||||
|
300: '#dfdfd8',
|
||||||
|
400: '#d0d0c8',
|
||||||
|
500: '#b7b7ae',
|
||||||
|
600: '#8f8f86',
|
||||||
|
700: '#6e6e67',
|
||||||
|
800: '#4f4f49',
|
||||||
|
900: '#31312d',
|
||||||
|
950: '#1e1e1b'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
background: '#f8f8f5',
|
||||||
|
hoverBackground: '#f1f1ed',
|
||||||
|
borderColor: 'rgba(17, 20, 23, 0.12)',
|
||||||
|
color: '#111417',
|
||||||
|
hoverColor: '#111417'
|
||||||
|
},
|
||||||
|
formField: {
|
||||||
|
background: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
disabledBackground: '#f1f1ed',
|
||||||
|
borderColor: 'rgba(17, 20, 23, 0.2)',
|
||||||
|
hoverBorderColor: '#8d593a',
|
||||||
|
focusBorderColor: '#8d593a',
|
||||||
|
color: '#111417',
|
||||||
|
placeholderColor: '#5e666e',
|
||||||
|
floatLabelColor: '#5e666e'
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
select: {
|
||||||
|
background: '#f8f8f5',
|
||||||
|
borderColor: 'rgba(17, 20, 23, 0.12)',
|
||||||
|
color: '#111417'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
background: '#f8f8f5',
|
||||||
|
borderColor: 'rgba(17, 20, 23, 0.12)',
|
||||||
|
color: '#111417'
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
background: '#f8f8f5',
|
||||||
|
borderColor: 'rgba(17, 20, 23, 0.12)',
|
||||||
|
color: '#111417'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
surface: {
|
||||||
|
0: '#17212b',
|
||||||
|
50: '#1d2733',
|
||||||
|
100: '#222d3a',
|
||||||
|
200: '#2d3947',
|
||||||
|
300: '#3a4858',
|
||||||
|
400: '#516173',
|
||||||
|
500: '#6c7c8d',
|
||||||
|
600: '#93a5b6',
|
||||||
|
700: '#b7c7d6',
|
||||||
|
800: '#dae4ec',
|
||||||
|
900: '#edf2f7',
|
||||||
|
950: '#f7fbff'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
background: '#1d2733',
|
||||||
|
hoverBackground: '#222d3a',
|
||||||
|
borderColor: 'rgba(146, 170, 194, 0.16)',
|
||||||
|
color: '#dae4ec',
|
||||||
|
hoverColor: '#dae4ec'
|
||||||
|
},
|
||||||
|
formField: {
|
||||||
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
disabledBackground: '#222d3a',
|
||||||
|
borderColor: 'rgba(146, 170, 194, 0.25)',
|
||||||
|
hoverBorderColor: '#4b90d9',
|
||||||
|
focusBorderColor: '#4b90d9',
|
||||||
|
color: '#dae4ec',
|
||||||
|
placeholderColor: '#93a5b6',
|
||||||
|
floatLabelColor: '#93a5b6'
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
select: {
|
||||||
|
background: '#1d2733',
|
||||||
|
borderColor: 'rgba(146, 170, 194, 0.16)',
|
||||||
|
color: '#dae4ec'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
background: '#1d2733',
|
||||||
|
borderColor: 'rgba(146, 170, 194, 0.16)',
|
||||||
|
color: '#dae4ec'
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
background: '#1d2733',
|
||||||
|
borderColor: 'rgba(146, 170, 194, 0.16)',
|
||||||
|
color: '#dae4ec'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AppPreset;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<app-page-header [eyebrow]="'auth.securityEyebrow' | translate" [title]="'auth.changePassword' | translate" [subtitle]="'auth.changePasswordSubtitle' | translate"></app-page-header>
|
||||||
|
|
||||||
|
<div class="change-password-shell">
|
||||||
|
<app-section-card [title]="'auth.changePassword' | translate" [subtitle]="'auth.passwordPanelSubtitle' | translate">
|
||||||
|
<div class="password-insights">
|
||||||
|
<div class="password-strength">
|
||||||
|
<span>{{ 'auth.passwordStrength' | translate }}</span>
|
||||||
|
<strong>{{ passwordStrengthLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="password-strength__track"><span [style.width.%]="passwordStrengthPercent"></span></div>
|
||||||
|
<div class="password-checklist">
|
||||||
|
<div class="password-checklist__item" [class.is-ready]="hasMinLength">
|
||||||
|
<i class="pi" [class.pi-check-circle]="hasMinLength" [class.pi-circle]="!hasMinLength"></i>
|
||||||
|
<span>{{ 'auth.ruleLength' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="password-checklist__item" [class.is-ready]="hasDigit">
|
||||||
|
<i class="pi" [class.pi-check-circle]="hasDigit" [class.pi-circle]="!hasDigit"></i>
|
||||||
|
<span>{{ 'auth.ruleDigit' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="password-checklist__item" [class.is-ready]="passwordsMatch">
|
||||||
|
<i class="pi" [class.pi-check-circle]="passwordsMatch" [class.pi-circle]="!passwordsMatch"></i>
|
||||||
|
<span>{{ 'auth.ruleMatch' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<app-section-card [title]="'auth.changePassword' | translate" [subtitle]="'auth.changePasswordCardSubtitle' | translate">
|
||||||
|
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form auth-form--grid change-password-form change-password-form--expanded">
|
||||||
|
<span class="form-field form-field--full">
|
||||||
|
<label>{{ 'auth.currentPassword' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="current_password" autocomplete="current-password" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field form-field--full">
|
||||||
|
<label>{{ 'auth.newPassword' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="new_password" autocomplete="new-password" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field form-field--full">
|
||||||
|
<label>{{ 'auth.confirmPassword' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="confirmPassword" autocomplete="new-password" />
|
||||||
|
</span>
|
||||||
|
<small class="form-field--full table-secondary">{{ passwordsMatch ? ('auth.passwordsMatchHint' | translate) : ('auth.passwordsMismatch' | translate) }}</small>
|
||||||
|
<small *ngIf="error" class="form-error form-field--full">{{ error }}</small>
|
||||||
|
<div class="dialog-actions form-field--full auth-card__actions auth-card__actions--split">
|
||||||
|
<a class="auth-link" routerLink="/">{{ 'auth.backToApp' | translate }}</a>
|
||||||
|
<button pButton type="submit" styleClass="auth-primary-btn" [disabled]="form.invalid || submitting" [loading]="submitting" [label]="'auth.changePassword' | translate"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</app-section-card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, TranslateModule, ButtonModule, InputTextModule, PageHeaderComponent, SectionCardComponent],
|
||||||
|
templateUrl: './change-password-page.component.html'
|
||||||
|
})
|
||||||
|
export class ChangePasswordPageComponent {
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
error = '';
|
||||||
|
submitting = false;
|
||||||
|
readonly form = this.fb.nonNullable.group({
|
||||||
|
current_password: ['', Validators.required],
|
||||||
|
new_password: ['', [Validators.required, Validators.minLength(4)]],
|
||||||
|
confirmPassword: ['', [Validators.required, Validators.minLength(4)]]
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
get hasMinLength(): boolean {
|
||||||
|
return (this.form.controls.new_password.value || '').length >= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasDigit(): boolean {
|
||||||
|
return /[0-9]/.test(this.form.controls.new_password.value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
get passwordStrengthPercent(): number {
|
||||||
|
const value = this.form.controls.new_password.value || '';
|
||||||
|
let score = 0;
|
||||||
|
if (value.length >= 8) score += 35;
|
||||||
|
if (/[A-Z]/.test(value)) score += 20;
|
||||||
|
if (/[a-z]/.test(value)) score += 15;
|
||||||
|
if (/[0-9]/.test(value)) score += 15;
|
||||||
|
if (/[^A-Za-z0-9]/.test(value)) score += 15;
|
||||||
|
return Math.min(100, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
get passwordStrengthLabel(): string {
|
||||||
|
const score = this.passwordStrengthPercent;
|
||||||
|
if (score >= 80) return this.ui.instant('auth.passwordStrong');
|
||||||
|
if (score >= 50) return this.ui.instant('auth.passwordMedium');
|
||||||
|
return this.ui.instant('auth.passwordWeak');
|
||||||
|
}
|
||||||
|
|
||||||
|
get passwordsMatch(): boolean {
|
||||||
|
const { new_password, confirmPassword } = this.form.getRawValue();
|
||||||
|
return !!new_password && new_password === confirmPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (this.form.invalid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.error = '';
|
||||||
|
const { current_password, new_password, confirmPassword } = this.form.getRawValue();
|
||||||
|
if (new_password !== confirmPassword) {
|
||||||
|
this.error = this.ui.instant('auth.passwordsMismatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
this.auth.changePassword(current_password, new_password).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.ui.success('toast.passwordChanged');
|
||||||
|
setTimeout(() => this.router.navigate(['/']), 500);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.error = err?.error?.detail ?? this.ui.instant('auth.changePasswordFailed');
|
||||||
|
this.submitting = false;
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/src/app/features/auth/login-page.component.html
Normal file
29
frontend/src/app/features/auth/login-page.component.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="auth-shell auth-shell--compact">
|
||||||
|
<app-auth-toolbar></app-auth-toolbar>
|
||||||
|
|
||||||
|
<div class="auth-card auth-card--wide">
|
||||||
|
<div class="auth-card__header">
|
||||||
|
<h2>{{ 'auth.login' | translate }}</h2>
|
||||||
|
<p>{{ 'auth.loginSubtitle' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form auth-form--grid">
|
||||||
|
<span class="form-field form-field--full">
|
||||||
|
<label>{{ 'auth.username' | translate }}</label>
|
||||||
|
<input pInputText formControlName="username" autocomplete="username" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="form-field form-field--full">
|
||||||
|
<label>{{ 'auth.password' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="password" autocomplete="current-password" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<small *ngIf="error" class="form-error form-field--full">{{ error }}</small>
|
||||||
|
|
||||||
|
<div class="dialog-actions form-field--full auth-card__actions auth-card__actions--split">
|
||||||
|
<a class="auth-link" routerLink="/register">{{ 'auth.register' | translate }}</a>
|
||||||
|
<button pButton type="submit" styleClass="auth-primary-btn" [disabled]="form.invalid || submitting" [loading]="submitting" [label]="'auth.login' | translate"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
49
frontend/src/app/features/auth/login-page.component.ts
Normal file
49
frontend/src/app/features/auth/login-page.component.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { AuthToolbarComponent } from '../../shared/auth/auth-toolbar.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, TranslateModule, ButtonModule, InputTextModule, AuthToolbarComponent],
|
||||||
|
templateUrl: './login-page.component.html'
|
||||||
|
})
|
||||||
|
export class LoginPageComponent {
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
error = '';
|
||||||
|
submitting = false;
|
||||||
|
readonly form = this.fb.nonNullable.group({
|
||||||
|
username: ['', Validators.required],
|
||||||
|
password: ['', Validators.required]
|
||||||
|
});
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (this.form.invalid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.error = '';
|
||||||
|
this.submitting = true;
|
||||||
|
const { username, password } = this.form.getRawValue();
|
||||||
|
this.auth.login(username, password).subscribe({
|
||||||
|
next: () => this.router.navigate(['/']),
|
||||||
|
error: (err) => {
|
||||||
|
this.error = err?.error?.detail ?? this.ui.instant('auth.loginFailed');
|
||||||
|
this.submitting = false;
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/src/app/features/auth/register-page.component.html
Normal file
28
frontend/src/app/features/auth/register-page.component.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<div class="auth-shell auth-shell--compact">
|
||||||
|
<app-auth-toolbar></app-auth-toolbar>
|
||||||
|
<div class="auth-card auth-card--wide">
|
||||||
|
<div class="auth-card__header">
|
||||||
|
<h2>{{ 'auth.register' | translate }}</h2>
|
||||||
|
</div>
|
||||||
|
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form auth-form--grid">
|
||||||
|
<span class="form-field form-field--full">
|
||||||
|
<label>{{ 'auth.username' | translate }}</label>
|
||||||
|
<input pInputText formControlName="username" autocomplete="username" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'auth.password' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="password" autocomplete="new-password" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'auth.confirmPassword' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="confirmPassword" autocomplete="new-password" />
|
||||||
|
</span>
|
||||||
|
<small *ngIf="error" class="form-error form-field--full">{{ error }}</small>
|
||||||
|
<small *ngIf="success" class="form-success form-field--full">{{ success }}</small>
|
||||||
|
<div class="dialog-actions form-field--full auth-card__actions auth-card__actions--split">
|
||||||
|
<a class="auth-link" routerLink="/login">{{ 'auth.backToLogin' | translate }}</a>
|
||||||
|
<button pButton type="submit" styleClass="auth-primary-btn" [disabled]="form.invalid || submitting" [loading]="submitting" [label]="'auth.register' | translate"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
59
frontend/src/app/features/auth/register-page.component.ts
Normal file
59
frontend/src/app/features/auth/register-page.component.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { AuthToolbarComponent } from '../../shared/auth/auth-toolbar.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, TranslateModule, ButtonModule, InputTextModule, AuthToolbarComponent],
|
||||||
|
templateUrl: './register-page.component.html'
|
||||||
|
})
|
||||||
|
export class RegisterPageComponent {
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
error = '';
|
||||||
|
success = '';
|
||||||
|
submitting = false;
|
||||||
|
readonly form = this.fb.nonNullable.group({
|
||||||
|
username: ['', [Validators.required, Validators.minLength(3)]],
|
||||||
|
password: ['', [Validators.required, Validators.minLength(4)]],
|
||||||
|
confirmPassword: ['', [Validators.required, Validators.minLength(4)]]
|
||||||
|
});
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (this.form.invalid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.error = '';
|
||||||
|
this.success = '';
|
||||||
|
const { username, password, confirmPassword } = this.form.getRawValue();
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
this.error = this.ui.instant('auth.passwordsMismatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
this.auth.register(username, password).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.success = this.ui.instant('auth.accountCreated');
|
||||||
|
setTimeout(() => this.router.navigate(['/login']), 500);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.error = err?.error?.detail ?? this.ui.instant('auth.registrationFailed');
|
||||||
|
this.submitting = false;
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<app-page-header
|
||||||
|
[eyebrow]="'dashboard.eyebrow' | translate"
|
||||||
|
[title]="'dashboard.title' | translate"
|
||||||
|
[subtitle]="'dashboard.subtitle' | translate"
|
||||||
|
></app-page-header>
|
||||||
|
|
||||||
|
<div class="stats-grid" *ngIf="data">
|
||||||
|
<app-stat-card [label]="'dashboard.managedRouters' | translate" [value]="data.routers_count" [hint]="'dashboard.managedRoutersHint' | translate" [tag]="'dashboard.inventoryTag' | translate" icon="pi pi-server" iconClass="icon-blue"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'dashboard.exportsCard' | translate" [value]="data.export_count" [hint]="'dashboard.exportsHint' | translate" [tag]="'dashboard.textTag' | translate" severity="success" icon="pi pi-file-export" iconClass="icon-emerald"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'dashboard.binaryCard' | translate" [value]="data.binary_count" [hint]="'dashboard.binaryHint' | translate" [tag]="'dashboard.binaryTag' | translate" severity="warn" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'dashboard.allFilesCard' | translate" [value]="data.total_backups" [hint]="'dashboard.allFilesHint' | translate" [tag]="'dashboard.archiveTag' | translate" severity="info" icon="pi pi-folder" iconClass="icon-violet"></app-stat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-section-card class="dashboard-operations-card" *ngIf="data" [title]="'dashboard.operationsTitle' | translate" [subtitle]="'dashboard.operationsSubtitle' | translate">
|
||||||
|
<div class="operations-center">
|
||||||
|
<div class="operations-center__actions">
|
||||||
|
<button pButton type="button" icon="pi pi-upload" [label]="'dashboard.exportAll' | translate" [loading]="exporting" (click)="exportAll()"></button>
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="'dashboard.binaryAll' | translate" [loading]="runningBinary" (click)="binaryAll()"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="operations-center__stats">
|
||||||
|
<div class="metric-tile metric-tile--feature">
|
||||||
|
<span>{{ 'dashboard.latestSnapshot' | translate }}</span>
|
||||||
|
<strong>{{ latestBackupLabel }}</strong>
|
||||||
|
<small>{{ latestBackupHint }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="metric-tile metric-tile--feature">
|
||||||
|
<span>{{ 'dashboard.coverageLabel' | translate }}</span>
|
||||||
|
<strong>{{ coveragePercent }}%</strong>
|
||||||
|
<small>{{ 'dashboard.coverageHint' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="metric-tile metric-tile--feature">
|
||||||
|
<span>{{ 'dashboard.weeklyActivityLabel' | translate }}</span>
|
||||||
|
<strong>{{ backupsLast7Days }}</strong>
|
||||||
|
<small>{{ 'dashboard.weeklyActivityHint' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="metric-tile metric-tile--feature">
|
||||||
|
<span>{{ 'dashboard.busiestRouterLabel' | translate }}</span>
|
||||||
|
<strong>{{ busiestRouterLabel }}</strong>
|
||||||
|
<small>{{ busiestRouterHint }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<div class="dashboard-focus-grid" *ngIf="data">
|
||||||
|
<app-section-card [title]="'dashboard.storageTitle' | translate" [subtitle]="'dashboard.storageSubtitle' | translate">
|
||||||
|
<div class="dashboard-focus-block dashboard-focus-block--pie">
|
||||||
|
<div class="dashboard-focus-pie" [style.background]="storageRingBackground">
|
||||||
|
<div class="dashboard-focus-pie__inner">
|
||||||
|
<strong>{{ formatPercent(usedPercent) }}</strong>
|
||||||
|
<span>{{ 'dashboard.diskUsage' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-focus-summary">
|
||||||
|
<p class="dashboard-focus-summary__lead">{{ 'dashboard.storageSubtitle' | translate }}</p>
|
||||||
|
<div class="dashboard-focus-metrics">
|
||||||
|
<div class="dashboard-focus-metric">
|
||||||
|
<span>{{ 'dashboard.totalDisk' | translate }}</span>
|
||||||
|
<strong>{{ formatBytes(data.storage.total) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-focus-metric">
|
||||||
|
<span>{{ 'dashboard.usedSpace' | translate }}</span>
|
||||||
|
<strong>{{ formatBytes(usedBytes) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-focus-metric">
|
||||||
|
<span>{{ 'dashboard.freeSpace' | translate }}</span>
|
||||||
|
<strong>{{ formatBytes(data.storage.free) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-focus-metric">
|
||||||
|
<span>{{ 'dashboard.folderUsage' | translate }}</span>
|
||||||
|
<strong>{{ formatBytes(data.storage.folder_used) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<app-section-card [title]="'dashboard.storageViewActivity' | translate" [subtitle]="'dashboard.storageViewActivityHint' | translate">
|
||||||
|
<ng-container *ngIf="backupActivityRows.length; else noSevenDayActivity">
|
||||||
|
<div class="dashboard-focus-activity">
|
||||||
|
<div class="dashboard-focus-activity__summary">
|
||||||
|
<p>{{ 'dashboard.weeklyActivityHint' | translate }}</p>
|
||||||
|
<strong>{{ backupsLast7Days }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-focus-columns">
|
||||||
|
<div class="dashboard-focus-column" *ngFor="let item of backupActivityRows" [attr.title]="item.fullLabel + ': ' + item.value">
|
||||||
|
<small>{{ item.label }}</small>
|
||||||
|
<div class="dashboard-focus-column__track">
|
||||||
|
<span [style.height.%]="item.height"></span>
|
||||||
|
</div>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</app-section-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #noSevenDayActivity>
|
||||||
|
<div class="empty-state compact-empty dashboard-focus-empty">
|
||||||
|
<i class="pi pi-history"></i>
|
||||||
|
<p>{{ 'dashboard.noActivity' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
413
frontend/src/app/features/dashboard/dashboard-page.component.ts
Normal file
413
frontend/src/app/features/dashboard/dashboard-page.component.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||||
|
import { StatCardComponent } from '../../shared/ui/stat-card.component';
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
routers_count: number;
|
||||||
|
export_count: number;
|
||||||
|
binary_count: number;
|
||||||
|
total_backups: number;
|
||||||
|
recent_logs: { timestamp: string; message: string }[];
|
||||||
|
storage: { total: number; used: number; free: number; folder_used: number; usage_percent: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupInventoryItem {
|
||||||
|
id: number;
|
||||||
|
router_id: number;
|
||||||
|
router_name?: string;
|
||||||
|
backup_type: 'export' | 'binary';
|
||||||
|
created_at: string;
|
||||||
|
file_size?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouterInventoryItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartTone = 'accent' | 'success' | 'info' | 'warning';
|
||||||
|
|
||||||
|
interface StorageChartRow {
|
||||||
|
labelKey: string;
|
||||||
|
value: string;
|
||||||
|
percent: number;
|
||||||
|
tone: ChartTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageActivityBar {
|
||||||
|
label: string;
|
||||||
|
fullLabel: string;
|
||||||
|
value: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouterBackupBar {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TranslateModule, ButtonModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
|
||||||
|
templateUrl: './dashboard-page.component.html'
|
||||||
|
})
|
||||||
|
export class DashboardPageComponent implements OnInit {
|
||||||
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
data?: DashboardData;
|
||||||
|
backups: BackupInventoryItem[] = [];
|
||||||
|
routers: RouterInventoryItem[] = [];
|
||||||
|
exporting = false;
|
||||||
|
runningBinary = false;
|
||||||
|
readonly activityPageSize = 6;
|
||||||
|
activityPage = 0;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
forkJoin({
|
||||||
|
dashboard: this.api.http.get<DashboardData>(`${this.api.baseUrl}/dashboard`),
|
||||||
|
backups: this.api.http.get<BackupInventoryItem[]>(`${this.api.baseUrl}/backups`),
|
||||||
|
routers: this.api.http.get<RouterInventoryItem[]>(`${this.api.baseUrl}/routers`)
|
||||||
|
}).subscribe(({ dashboard, backups, routers }) => {
|
||||||
|
this.data = dashboard;
|
||||||
|
this.backups = backups;
|
||||||
|
this.routers = routers;
|
||||||
|
this.activityPage = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exportAll() {
|
||||||
|
if (this.exporting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.exporting = true;
|
||||||
|
this.api.http.post<any[]>(`${this.api.baseUrl}/backups/routers/export-all`, {}).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.ui.success('toast.exportedRouters', { count: result.filter((item) => item.status === 'ok').length });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.exporting = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
binaryAll() {
|
||||||
|
if (this.runningBinary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.runningBinary = true;
|
||||||
|
this.api.http.post<any[]>(`${this.api.baseUrl}/backups/routers/binary-all`, {}).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.ui.success('toast.binaryCompletedRouters', { count: result.filter((item) => item.status === 'ok').length });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.runningBinary = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get usedBytes(): number {
|
||||||
|
const storage = this.data?.storage;
|
||||||
|
if (!storage) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const computed = Math.max(0, Number(storage.total || 0) - Number(storage.free || 0));
|
||||||
|
if (computed > 0) {
|
||||||
|
return computed;
|
||||||
|
}
|
||||||
|
return Math.max(0, Number(storage.used || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
get usedPercent(): number {
|
||||||
|
const total = Number(this.data?.storage.total || 0);
|
||||||
|
return total > 0 ? (this.usedBytes / total) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get freePercent(): number {
|
||||||
|
const storage = this.data?.storage;
|
||||||
|
const total = Number(storage?.total || 0);
|
||||||
|
const free = Math.max(0, Number(storage?.free || 0));
|
||||||
|
return total > 0 ? (free / total) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get folderPercent(): number {
|
||||||
|
const storage = this.data?.storage;
|
||||||
|
const total = Number(storage?.total || 0);
|
||||||
|
const folderUsed = Math.max(0, Number(storage?.folder_used || 0));
|
||||||
|
return total > 0 ? (folderUsed / total) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get storageCapacityRows(): StorageChartRow[] {
|
||||||
|
const storage = this.data?.storage;
|
||||||
|
if (!storage) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ labelKey: 'dashboard.totalDisk', value: this.formatBytes(storage.total), percent: 100, tone: 'accent' },
|
||||||
|
{ labelKey: 'dashboard.usedSpace', value: this.formatBytes(this.usedBytes), percent: this.usedPercent, tone: 'warning' },
|
||||||
|
{ labelKey: 'dashboard.freeSpace', value: this.formatBytes(storage.free), percent: this.freePercent, tone: 'success' },
|
||||||
|
{ labelKey: 'dashboard.folderUsage', value: this.formatBytes(storage.folder_used), percent: this.folderPercent, tone: 'info' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get backupMixRows(): StorageChartRow[] {
|
||||||
|
const exportCount = Number(this.data?.export_count || 0);
|
||||||
|
const binaryCount = Number(this.data?.binary_count || 0);
|
||||||
|
const total = exportCount + binaryCount;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
labelKey: 'dashboard.exportsCard',
|
||||||
|
value: String(exportCount),
|
||||||
|
percent: total > 0 ? (exportCount / total) * 100 : 0,
|
||||||
|
tone: 'accent'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: 'dashboard.binaryCard',
|
||||||
|
value: String(binaryCount),
|
||||||
|
percent: total > 0 ? (binaryCount / total) * 100 : 0,
|
||||||
|
tone: 'warning'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get backupActivityRows(): StorageActivityBar[] {
|
||||||
|
if (!this.backups.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const backup of this.backups) {
|
||||||
|
const value = new Date(backup.created_at);
|
||||||
|
value.setHours(0, 0, 0, 0);
|
||||||
|
const key = value.toISOString().slice(0, 10);
|
||||||
|
counts.set(key, (counts.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: StorageActivityBar[] = [];
|
||||||
|
for (let offset = 6; offset >= 0; offset -= 1) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() - offset);
|
||||||
|
const key = date.toISOString().slice(0, 10);
|
||||||
|
const label = `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
items.push({ label, fullLabel: key, value: counts.get(key) || 0, height: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxValue = Math.max(...items.map((item) => item.value), 0);
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
height: item.value > 0 && maxValue > 0 ? Math.max(16, Math.round((item.value / maxValue) * 100)) : 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get routerBackupRows(): RouterBackupBar[] {
|
||||||
|
if (!this.backups.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const counters = new Map<number, RouterBackupBar>();
|
||||||
|
for (const backup of this.backups) {
|
||||||
|
const entry = counters.get(backup.router_id) || { label: backup.router_name || `#${backup.router_id}`, value: 0, percent: 0 };
|
||||||
|
entry.value += 1;
|
||||||
|
counters.set(backup.router_id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = Array.from(counters.values()).sort((a, b) => b.value - a.value).slice(0, 5);
|
||||||
|
const maxValue = Math.max(...sorted.map((item) => item.value), 0);
|
||||||
|
return sorted.map((item) => ({ ...item, percent: maxValue > 0 ? (item.value / maxValue) * 100 : 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
get averageBackupsPerRouter(): string {
|
||||||
|
if (!this.data?.routers_count) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
return (this.data.total_backups / this.data.routers_count).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
get coveragePercent(): number {
|
||||||
|
if (!this.routers.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const routersWithBackups = new Set(this.backups.map((item) => item.router_id)).size;
|
||||||
|
return Math.round((routersWithBackups / this.routers.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
get exportsSharePercent(): number {
|
||||||
|
if (!this.backups.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.round((this.backups.filter((item) => item.backup_type === 'export').length / this.backups.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
get backupsLast7Days(): number {
|
||||||
|
const threshold = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
return this.backups.filter((item) => new Date(item.created_at).getTime() >= threshold).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get latestBackupLabel(): string {
|
||||||
|
const latest = this.latestBackup;
|
||||||
|
if (!latest) {
|
||||||
|
return this.ui.instant('dashboard.noneLabel');
|
||||||
|
}
|
||||||
|
return latest.router_name || `#${latest.router_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get latestBackupHint(): string {
|
||||||
|
const latest = this.latestBackup;
|
||||||
|
if (!latest) {
|
||||||
|
return this.ui.instant('dashboard.noActivity');
|
||||||
|
}
|
||||||
|
return `${latest.backup_type === 'export' ? this.ui.instant('files.exportType') : this.ui.instant('files.binaryType')} · ${this.relativeAge(latest.created_at)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get busiestRouterLabel(): string {
|
||||||
|
const busiest = this.busiestRouter;
|
||||||
|
if (!busiest) {
|
||||||
|
return this.ui.instant('dashboard.noneLabel');
|
||||||
|
}
|
||||||
|
return busiest.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get busiestRouterHint(): string {
|
||||||
|
const busiest = this.busiestRouter;
|
||||||
|
if (!busiest) {
|
||||||
|
return this.ui.instant('dashboard.noActivity');
|
||||||
|
}
|
||||||
|
return this.ui.instant('dashboard.routerSnapshotsHint', { count: busiest.count });
|
||||||
|
}
|
||||||
|
|
||||||
|
get activityToday(): number {
|
||||||
|
if (!this.data?.recent_logs?.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
return this.data.recent_logs.filter((log) => {
|
||||||
|
const value = new Date(log.timestamp);
|
||||||
|
return value.getFullYear() === today.getFullYear() && value.getMonth() === today.getMonth() && value.getDate() === today.getDate();
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get activityPageCount(): number {
|
||||||
|
const total = this.data?.recent_logs?.length || 0;
|
||||||
|
return Math.max(1, Math.ceil(total / this.activityPageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
get pagedRecentLogs() {
|
||||||
|
if (!this.data?.recent_logs?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const start = this.activityPage * this.activityPageSize;
|
||||||
|
return this.data.recent_logs.slice(start, start + this.activityPageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
get activityRangeLabel(): string {
|
||||||
|
const total = this.data?.recent_logs?.length || 0;
|
||||||
|
if (!total) {
|
||||||
|
return '0 / 0';
|
||||||
|
}
|
||||||
|
const start = this.activityPage * this.activityPageSize + 1;
|
||||||
|
const end = Math.min(total, start + this.activityPageSize - 1);
|
||||||
|
return `${start}-${end} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousActivityPage() {
|
||||||
|
this.activityPage = Math.max(0, this.activityPage - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextActivityPage() {
|
||||||
|
this.activityPage = Math.min(this.activityPageCount - 1, this.activityPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
get storageRingBackground(): string {
|
||||||
|
const safePercent = Math.min(100, Math.max(0, this.usedPercent));
|
||||||
|
return `conic-gradient(var(--accent) 0deg ${safePercent * 3.6}deg, rgba(129, 149, 167, 0.18) ${safePercent * 3.6}deg 360deg)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(value: number): string {
|
||||||
|
if (!value) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = value;
|
||||||
|
let unit = 0;
|
||||||
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatPercent(value: number): string {
|
||||||
|
return `${Number(value || 0).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeAge(value: string): string {
|
||||||
|
const diff = Date.now() - new Date(value).getTime();
|
||||||
|
const hours = Math.floor(diff / 3_600_000);
|
||||||
|
if (hours < 1) {
|
||||||
|
const minutes = Math.max(1, Math.floor(diff / 60_000));
|
||||||
|
return this.ui.instant('files.minutesAgo', { value: minutes });
|
||||||
|
}
|
||||||
|
if (hours < 24) {
|
||||||
|
return this.ui.instant('files.hoursAgo', { value: hours });
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return this.ui.instant('files.daysAgo', { value: days });
|
||||||
|
}
|
||||||
|
|
||||||
|
activityTone(message: string): 'success' | 'danger' | 'warning' | 'info' {
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
if (normalized.includes('fail') || normalized.includes('error')) return 'danger';
|
||||||
|
if (normalized.includes('cleanup') || normalized.includes('retention')) return 'warning';
|
||||||
|
if (normalized.includes('upload') || normalized.includes('email')) return 'info';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
activityIcon(message: string): string {
|
||||||
|
const tone = this.activityTone(message);
|
||||||
|
if (tone === 'danger') return 'pi pi-exclamation-triangle';
|
||||||
|
if (tone === 'warning') return 'pi pi-broom';
|
||||||
|
if (tone === 'info') return 'pi pi-send';
|
||||||
|
return 'pi pi-check';
|
||||||
|
}
|
||||||
|
|
||||||
|
activityLabel(message: string): string {
|
||||||
|
const tone = this.activityTone(message);
|
||||||
|
if (tone === 'danger') return this.ui.instant('dashboard.activityFailure');
|
||||||
|
if (tone === 'warning') return this.ui.instant('dashboard.activityMaintenance');
|
||||||
|
if (tone === 'info') return this.ui.instant('dashboard.activityDelivery');
|
||||||
|
return this.ui.instant('dashboard.activitySuccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
private get latestBackup(): BackupInventoryItem | undefined {
|
||||||
|
return this.backups.slice().sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get busiestRouter(): { name: string; count: number } | null {
|
||||||
|
if (!this.backups.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const counters = new Map<number, { name: string; count: number }>();
|
||||||
|
for (const backup of this.backups) {
|
||||||
|
const entry = counters.get(backup.router_id) || { name: backup.router_name || `#${backup.router_id}`, count: 0 };
|
||||||
|
entry.count += 1;
|
||||||
|
counters.set(backup.router_id, entry);
|
||||||
|
}
|
||||||
|
return Array.from(counters.values()).sort((a, b) => b.count - a.count)[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<app-page-header
|
||||||
|
[eyebrow]="'diffConfigs.eyebrow' | translate"
|
||||||
|
[title]="'diffConfigs.title' | translate"
|
||||||
|
[subtitle]="'diffConfigs.subtitle' | translate"
|
||||||
|
></app-page-header>
|
||||||
|
|
||||||
|
<div class="stats-grid compact-grid">
|
||||||
|
<app-stat-card [label]="'diffConfigs.exportsCard' | translate" [value]="availableExportsCount" [hint]="'diffConfigs.exportsCardHint' | translate" [tag]="'files.exportType' | translate" severity="success" icon="pi pi-file-export" iconClass="icon-emerald"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'diffConfigs.scopeCard' | translate" [value]="selectedRouterLabel" [hint]="'diffConfigs.scopeCardHint' | translate" [tag]="'diffConfigs.scopeTag' | translate" severity="info" icon="pi pi-server" iconClass="icon-blue"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'diffConfigs.readyCard' | translate" [value]="compareReady ? ('common.ok' | translate) : ('common.idle' | translate)" [hint]="'diffConfigs.readyCardHint' | translate" [tag]="'diffConfigs.readyTag' | translate" severity="warn" icon="pi pi-code" iconClass="icon-amber"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'diffConfigs.lastDiffCard' | translate" [value]="lastDiffLabel" [hint]="'diffConfigs.lastDiffCardHint' | translate" [tag]="'diffConfigs.lastDiffTag' | translate" severity="secondary" icon="pi pi-history" iconClass="icon-violet"></app-stat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-section-card [title]="'diffConfigs.workspaceTitle' | translate" [subtitle]="'diffConfigs.workspaceSubtitle' | translate">
|
||||||
|
<div class="diff-workspace">
|
||||||
|
<div class="diff-workspace__toolbar">
|
||||||
|
<span class="form-field diff-workspace__router">
|
||||||
|
<label>{{ 'files.routerLabel' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value" (onChange)="load()"></p-select>
|
||||||
|
</span>
|
||||||
|
<div class="diff-workspace__actions">
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-refresh" [label]="'common.reset' | translate" (click)="routerId = null; compareLeftId = null; compareRightId = null; load()"></button>
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-star" [label]="'files.compareLatestPair' | translate" (click)="fillLatestPair()" [disabled]="availableExportsCount < 2"></button>
|
||||||
|
<button pButton type="button" severity="help" icon="pi pi-code" [label]="'files.compareSelected' | translate" (click)="openStructuredDiff()" [disabled]="!compareReady" [loading]="compareBusy"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diff-workspace__pair">
|
||||||
|
<div class="diff-pick-card" [class.is-selected]="!!compareLeft">
|
||||||
|
<div class="diff-pick-card__header">
|
||||||
|
<strong>{{ 'files.compareOlder' | translate }}</strong>
|
||||||
|
<p-tag [value]="compareLeft ? ('common.ok' | translate) : ('diffConfigs.waitingTag' | translate)" [severity]="compareLeft ? 'success' : 'secondary'"></p-tag>
|
||||||
|
</div>
|
||||||
|
<p-select [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareLeftId" optionLabel="label" optionValue="value" [placeholder]="'files.pickOlder' | translate"></p-select>
|
||||||
|
<div class="diff-pick-card__meta" *ngIf="compareLeft as item">
|
||||||
|
<strong>{{ item.file_name }}</strong>
|
||||||
|
<small>{{ item.router_name || item.router_id }} · {{ relativeAge(item.created_at) }}</small>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button pButton type="button" severity="secondary" size="small" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-sort-alt" styleClass="diff-workspace__swap" (click)="swapCompare()" [disabled]="!compareLeftId && !compareRightId"></button>
|
||||||
|
|
||||||
|
<div class="diff-pick-card" [class.is-selected]="!!compareRight">
|
||||||
|
<div class="diff-pick-card__header">
|
||||||
|
<strong>{{ 'files.compareNewer' | translate }}</strong>
|
||||||
|
<p-tag [value]="compareRight ? ('common.ok' | translate) : ('diffConfigs.waitingTag' | translate)" [severity]="compareRight ? 'success' : 'secondary'"></p-tag>
|
||||||
|
</div>
|
||||||
|
<p-select [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareRightId" optionLabel="label" optionValue="value" [placeholder]="'files.pickNewer' | translate"></p-select>
|
||||||
|
<div class="diff-pick-card__meta" *ngIf="compareRight as item">
|
||||||
|
<strong>{{ item.file_name }}</strong>
|
||||||
|
<small>{{ item.router_name || item.router_id }} · {{ relativeAge(item.created_at) }}</small>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button pButton type="button" severity="secondary" size="small" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<app-section-card class="diff-configs-table-section" [title]="'diffConfigs.tableTitle' | translate" [subtitle]="'diffConfigs.tableSubtitle' | translate">
|
||||||
|
<p-table [value]="exportFiles" [loading]="loading" [rows]="8" [paginator]="exportFiles.length > 8" responsiveLayout="scroll" styleClass="app-table repository-table">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr>
|
||||||
|
<th>{{ 'files.fileColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.routerColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.createdColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.compareColumn' | translate }}</th>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="body" let-item>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ item.file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.checksum' | translate }}: {{ checksumShort(item.checksum) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ item.router_name || item.router_id }}</div>
|
||||||
|
<small class="table-secondary">ID {{ item.router_id }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ item.created_at | date: 'dd.MM.yyyy HH:mm' }}</div>
|
||||||
|
<small class="table-secondary">{{ relativeAge(item.created_at) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions table-actions--labels table-actions--stack">
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-arrow-left" [label]="'files.setOlder' | translate" (click)="assignCompare('left', item)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-arrow-right" [label]="'files.setNewer' | translate" (click)="assignCompare('right', item)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="info" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<p-dialog [(visible)]="previewVisible" [modal]="true" [header]="previewTitle || ('files.previewDialogTitle' | translate)" [style]="{ width: 'min(1100px, 92vw)' }" styleClass="preview-dialog">
|
||||||
|
<pre class="code-preview preview-dialog__content">{{ viewedExport }}</pre>
|
||||||
|
</p-dialog>
|
||||||
|
|
||||||
|
<p-dialog [(visible)]="diffVisible" [modal]="true" [header]="'files.diffDialogTitle' | translate" [style]="{ width: 'min(1420px, 96vw)' }" styleClass="preview-dialog preview-dialog--diff">
|
||||||
|
<div class="diff-layout" *ngIf="diffData as diff">
|
||||||
|
<div class="diff-layout__summary">
|
||||||
|
<div>
|
||||||
|
<div class="table-primary">{{ diff.left_file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.compareOlder' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="diff-layout__summary-arrow"><i class="pi pi-arrow-right"></i></div>
|
||||||
|
<div>
|
||||||
|
<div class="table-primary">{{ diff.right_file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.compareNewer' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="diff-stats" *ngIf="diff.stats">
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--added">+{{ diff.stats.added }}</span>
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--removed">-{{ diff.stats.removed }}</span>
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--modified">~{{ diff.stats.modified }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions preview-dialog__actions">
|
||||||
|
<button pButton type="button" severity="help" icon="pi pi-external-link" [label]="'files.openHtmlDiff' | translate" (click)="openHtmlDiff()"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="github-diff" *ngIf="diff.lines?.length; else plainDiffFallback">
|
||||||
|
<div class="github-diff__row" *ngFor="let line of diff.lines" [attr.data-type]="line.type">
|
||||||
|
<div class="github-diff__cell github-diff__cell--left">
|
||||||
|
<span class="github-diff__number">{{ line.left_number || '' }}</span>
|
||||||
|
<pre>{{ line.left_text || ' ' }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="github-diff__cell github-diff__cell--right">
|
||||||
|
<span class="github-diff__number">{{ line.right_number || '' }}</span>
|
||||||
|
<pre>{{ line.right_text || ' ' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #plainDiffFallback>
|
||||||
|
<pre class="code-preview preview-dialog__content">{{ diff.diff_text }}</pre>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</p-dialog>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { HttpParams } from '@angular/common/http';
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { DialogModule } from 'primeng/dialog';
|
||||||
|
import { SelectModule } from 'primeng/select';
|
||||||
|
import { TableModule } from 'primeng/table';
|
||||||
|
import { TagModule } from 'primeng/tag';
|
||||||
|
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||||
|
import { StatCardComponent } from '../../shared/ui/stat-card.component';
|
||||||
|
|
||||||
|
interface BackupFile {
|
||||||
|
id: number;
|
||||||
|
router_id: number;
|
||||||
|
router_name?: string;
|
||||||
|
file_name: string;
|
||||||
|
backup_type: 'export' | 'binary';
|
||||||
|
created_at: string;
|
||||||
|
checksum?: string | null;
|
||||||
|
file_size?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupDiffLine {
|
||||||
|
type: 'context' | 'added' | 'removed' | 'modified';
|
||||||
|
left_number?: number | null;
|
||||||
|
right_number?: number | null;
|
||||||
|
left_text: string;
|
||||||
|
right_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupDiffStats {
|
||||||
|
added: number;
|
||||||
|
removed: number;
|
||||||
|
modified: number;
|
||||||
|
context: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupDiffResponse {
|
||||||
|
left_backup_id: number;
|
||||||
|
right_backup_id: number;
|
||||||
|
left_file_name?: string | null;
|
||||||
|
right_file_name?: string | null;
|
||||||
|
diff_text: string;
|
||||||
|
lines: BackupDiffLine[];
|
||||||
|
stats?: BackupDiffStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, DialogModule, SelectModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
|
||||||
|
templateUrl: './diff-configs-page.component.html'
|
||||||
|
})
|
||||||
|
export class DiffConfigsPageComponent implements OnInit {
|
||||||
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
files: BackupFile[] = [];
|
||||||
|
routers: { id: number; name: string }[] = [];
|
||||||
|
routerId: number | null = null;
|
||||||
|
compareLeftId: number | null = null;
|
||||||
|
compareRightId: number | null = null;
|
||||||
|
previewVisible = false;
|
||||||
|
diffVisible = false;
|
||||||
|
compareBusy = false;
|
||||||
|
loading = false;
|
||||||
|
previewTitle = '';
|
||||||
|
viewedExport = '';
|
||||||
|
diffData: BackupDiffResponse | null = null;
|
||||||
|
lastCompared: { left: number; right: number } | null = null;
|
||||||
|
|
||||||
|
get routerOptions() {
|
||||||
|
return [{ label: this.ui.instant('files.allRouters'), value: null }, ...this.routers.map((item) => ({ label: item.name, value: item.id }))];
|
||||||
|
}
|
||||||
|
|
||||||
|
get exportFiles(): BackupFile[] {
|
||||||
|
return this.files
|
||||||
|
.filter((item) => item.backup_type === 'export' && (this.routerId === null || item.router_id === this.routerId))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareOptions() {
|
||||||
|
return this.exportFiles.map((item) => ({
|
||||||
|
label: `${item.router_name || item.router_id} · ${item.file_name}`,
|
||||||
|
value: item.id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareLeft(): BackupFile | undefined {
|
||||||
|
return this.files.find((item) => item.id === this.compareLeftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareRight(): BackupFile | undefined {
|
||||||
|
return this.files.find((item) => item.id === this.compareRightId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareReady(): boolean {
|
||||||
|
return !!this.compareLeftId && !!this.compareRightId && this.compareLeftId !== this.compareRightId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get availableExportsCount(): number {
|
||||||
|
return this.exportFiles.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedRouterLabel(): string {
|
||||||
|
if (this.routerId === null) {
|
||||||
|
return this.ui.instant('files.allRouters');
|
||||||
|
}
|
||||||
|
return this.routers.find((item) => item.id === this.routerId)?.name || `#${this.routerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastDiffLabel(): string {
|
||||||
|
if (!this.diffData?.left_file_name || !this.diffData?.right_file_name) {
|
||||||
|
return this.ui.instant('diffConfigs.noneSelected');
|
||||||
|
}
|
||||||
|
return `${this.diffData.left_file_name} → ${this.diffData.right_file_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.api.http.get<any[]>(`${this.api.baseUrl}/routers`).subscribe((routers) => {
|
||||||
|
this.routers = routers.map((item) => ({ id: item.id, name: item.name }));
|
||||||
|
});
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.loading = true;
|
||||||
|
let params = new HttpParams().set('backup_type', 'export').set('sort_by', 'created_at').set('order', 'desc');
|
||||||
|
if (this.routerId !== null) {
|
||||||
|
params = params.set('router_id', String(this.routerId));
|
||||||
|
}
|
||||||
|
this.api.http.get<BackupFile[]>(`${this.api.baseUrl}/backups`, { params }).subscribe({
|
||||||
|
next: (files) => {
|
||||||
|
this.files = files;
|
||||||
|
if (this.compareLeftId && !this.files.some((item) => item.id === this.compareLeftId)) {
|
||||||
|
this.compareLeftId = null;
|
||||||
|
}
|
||||||
|
if (this.compareRightId && !this.files.some((item) => item.id === this.compareRightId)) {
|
||||||
|
this.compareRightId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assignCompare(side: 'left' | 'right', item: BackupFile) {
|
||||||
|
if (side === 'left') {
|
||||||
|
this.compareLeftId = item.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.compareRightId = item.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
fillLatestPair() {
|
||||||
|
if (this.exportFiles.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [right, left] = this.exportFiles;
|
||||||
|
this.compareLeftId = left.id;
|
||||||
|
this.compareRightId = right.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
swapCompare() {
|
||||||
|
const left = this.compareLeftId;
|
||||||
|
this.compareLeftId = this.compareRightId;
|
||||||
|
this.compareRightId = left;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewExport(item: BackupFile) {
|
||||||
|
this.api.http.get<{ content: string }>(`${this.api.baseUrl}/backups/${item.id}/view`).subscribe((response) => {
|
||||||
|
this.viewedExport = response.content;
|
||||||
|
this.previewTitle = item.file_name;
|
||||||
|
this.ui.clear();
|
||||||
|
this.previewVisible = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openStructuredDiff() {
|
||||||
|
if (!this.compareReady || this.compareBusy || !this.compareLeftId || !this.compareRightId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.compareBusy = true;
|
||||||
|
const [left, right] = this.sortPairByDate(this.compareLeftId, this.compareRightId);
|
||||||
|
this.lastCompared = { left, right };
|
||||||
|
this.api.http.get<BackupDiffResponse>(`${this.api.baseUrl}/backups/${left}/diff/${right}`).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.diffData = response;
|
||||||
|
this.ui.clear();
|
||||||
|
this.diffVisible = true;
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.compareBusy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openHtmlDiff() {
|
||||||
|
if (!this.lastCompared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.http
|
||||||
|
.get(`${this.api.baseUrl}/backups/${this.lastCompared.left}/diff/${this.lastCompared.right}/html`, { responseType: 'text' })
|
||||||
|
.subscribe((html) => {
|
||||||
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeAge(value: string): string {
|
||||||
|
const diff = Date.now() - new Date(value).getTime();
|
||||||
|
const hours = Math.floor(diff / 3_600_000);
|
||||||
|
if (hours < 1) {
|
||||||
|
const minutes = Math.max(1, Math.floor(diff / 60_000));
|
||||||
|
return this.ui.instant('files.minutesAgo', { value: minutes });
|
||||||
|
}
|
||||||
|
if (hours < 24) {
|
||||||
|
return this.ui.instant('files.hoursAgo', { value: hours });
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return this.ui.instant('files.daysAgo', { value: days });
|
||||||
|
}
|
||||||
|
|
||||||
|
checksumShort(value?: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return 'n/a';
|
||||||
|
}
|
||||||
|
return `${value.slice(0, 8)}…${value.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortPairByDate(firstId: number, secondId: number): [number, number] {
|
||||||
|
const first = this.files.find((item) => item.id === firstId);
|
||||||
|
const second = this.files.find((item) => item.id === secondId);
|
||||||
|
if (!first || !second) {
|
||||||
|
return [firstId, secondId];
|
||||||
|
}
|
||||||
|
return new Date(first.created_at).getTime() <= new Date(second.created_at).getTime() ? [firstId, secondId] : [secondId, firstId];
|
||||||
|
}
|
||||||
|
}
|
||||||
191
frontend/src/app/features/files/files-page.component.html
Normal file
191
frontend/src/app/features/files/files-page.component.html
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<app-page-header [eyebrow]="'files.eyebrow' | translate" [title]="'files.title' | translate" [subtitle]="'files.subtitle' | translate">
|
||||||
|
<div header-actions class="header-actions-row">
|
||||||
|
<button pButton type="button" icon="pi pi-download" [label]="'files.downloadZip' | translate" [loading]="bulkBusy && selectedIds.length > 0" (click)="bulkDownload()" [disabled]="selectedIds.length===0"></button>
|
||||||
|
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" [loading]="bulkBusy && selectedIds.length > 0" (click)="bulkDelete()" [disabled]="selectedIds.length===0"></button>
|
||||||
|
</div>
|
||||||
|
</app-page-header>
|
||||||
|
|
||||||
|
<div class="stats-grid compact-grid">
|
||||||
|
<app-stat-card [label]="'files.visibleFiles' | translate" [value]="files.length" [hint]="'files.visibleFilesHint' | translate" [tag]="'files.liveTag' | translate" icon="pi pi-folder-open" iconClass="icon-blue"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'files.selected' | translate" [value]="selectedIds.length" [hint]="'files.selectedHint' | translate" [tag]="'files.batchTag' | translate" severity="secondary" icon="pi pi-check-square" iconClass="icon-violet"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'files.exportsCard' | translate" [value]="exportCount" [hint]="'files.exportsHint' | translate" [tag]="'dashboard.textTag' | translate" severity="success" icon="pi pi-file-export" iconClass="icon-emerald"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'files.binaryCard' | translate" [value]="binaryCount" [hint]="'files.binaryHint' | translate" [tag]="'dashboard.binaryTag' | translate" severity="warn" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-section-card [title]="'files.filtersTitle' | translate" [subtitle]="'files.filtersSubtitle' | translate">
|
||||||
|
<div class="repository-toolbar">
|
||||||
|
<span class="form-field repository-toolbar__search">
|
||||||
|
<label>{{ 'files.searchLabel' | translate }}</label>
|
||||||
|
<span class="p-input-icon-left">
|
||||||
|
<i class="pi pi-search"></i>
|
||||||
|
<input pInputText [(ngModel)]="search" [placeholder]="'files.searchPlaceholder' | translate" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'files.typeLabel' | translate }}</label>
|
||||||
|
<p-select [options]="typeOptions" [(ngModel)]="backupType" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'files.routerLabel' | translate }}</label>
|
||||||
|
<p-select [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'files.dateLabel' | translate }}</label>
|
||||||
|
<input pInputText type="date" [(ngModel)]="createdOn" [placeholder]="'files.datePlaceholder' | translate" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'files.sortLabel' | translate }}</label>
|
||||||
|
<p-select [options]="sortOptions" [(ngModel)]="sortBy" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'files.orderLabel' | translate }}</label>
|
||||||
|
<p-select [options]="orderOptions" [(ngModel)]="order" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="filters-actions repository-toolbar__actions">
|
||||||
|
<button pButton type="button" [label]="'common.apply' | translate" icon="pi pi-filter" [loading]="loading" (click)="load()"></button>
|
||||||
|
<button pButton type="button" severity="secondary" [label]="'common.reset' | translate" icon="pi pi-refresh" (click)="resetFilters()"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repository-compare">
|
||||||
|
<div class="repository-compare__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'files.compareTitle' | translate }}</strong>
|
||||||
|
<p>{{ 'files.compareSubtitle' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="repository-compare__status">
|
||||||
|
<p-tag [value]="exportCount + ' ' + ('files.exportPoolLabel' | translate)" severity="success"></p-tag>
|
||||||
|
<p-tag [value]="compareContextLabel" [severity]="compareReady ? 'info' : 'secondary'"></p-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repository-compare__grid">
|
||||||
|
<div class="compare-strip__slot repository-compare__slot">
|
||||||
|
<label>{{ 'files.compareOlder' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareLeftId" optionLabel="label" optionValue="value" [placeholder]="'files.pickOlder' | translate"></p-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-sort-alt" styleClass="compare-strip__swap" (click)="swapCompare()" [disabled]="!compareLeftId && !compareRightId"></button>
|
||||||
|
|
||||||
|
<div class="compare-strip__slot repository-compare__slot">
|
||||||
|
<label>{{ 'files.compareNewer' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="compareOptions" [(ngModel)]="compareRightId" optionLabel="label" optionValue="value" [placeholder]="'files.pickNewer' | translate"></p-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compare-strip__actions repository-compare__actions">
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-star" [label]="'files.compareLatestPair' | translate" (click)="fillLatestPair()" [disabled]="exportFiles.length < 2"></button>
|
||||||
|
<button pButton type="button" severity="help" icon="pi pi-code" [label]="'files.compareSelected' | translate" (click)="openStructuredDiff()" [disabled]="!compareReady" [loading]="compareBusy"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<app-section-card class="repository-table-section" [title]="'files.tableTitle' | translate" [subtitle]="'files.tableSubtitle' | translate">
|
||||||
|
<p-table [value]="files" [(selection)]="selected" dataKey="id" [rows]="10" [loading]="loading" [paginator]="files.length > 10" responsiveLayout="scroll" styleClass="app-table repository-table">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr>
|
||||||
|
<th style="width:3rem"></th>
|
||||||
|
<th>{{ 'files.fileColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.routerColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.typeColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.createdColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.sizeColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.compareColumn' | translate }}</th>
|
||||||
|
<th>{{ 'files.actionsColumn' | translate }}</th>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="body" let-item>
|
||||||
|
<tr [attr.data-compare-role]="compareRole(item)">
|
||||||
|
<td><p-tableCheckbox [value]="item"></p-tableCheckbox></td>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ item.file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.checksum' | translate }}: {{ checksumShort(item.checksum) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ item.router_name || item.router_id }}</div>
|
||||||
|
<small class="table-secondary">{{ deviceLabel(item) }} · ID {{ item.router_id }}</small>
|
||||||
|
</td>
|
||||||
|
<td><p-tag [value]="item.backup_type === 'export' ? ('files.exportType' | translate) : ('files.binaryType' | translate)" [severity]="item.backup_type === 'export' ? 'success' : 'warn'"></p-tag></td>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ item.created_at | date: 'dd.MM.yyyy HH:mm' }}</div>
|
||||||
|
<small class="table-secondary">{{ relativeAge(item.created_at) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ formatBytes(item.file_size) }}</div>
|
||||||
|
<small class="table-secondary">{{ fileExtension(item) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions table-actions--stack" *ngIf="item.backup_type === 'export'; else noCompare">
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-arrow-left" [label]="'files.setOlder' | translate" (click)="assignCompare('left', item)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-arrow-right" [label]="'files.setNewer' | translate" (click)="assignCompare('right', item)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="help" icon="pi pi-code" [label]="'files.latestForRouter' | translate" (click)="compareClosestForRouter(item)"></button>
|
||||||
|
</div>
|
||||||
|
<ng-template #noCompare>
|
||||||
|
<small class="table-secondary">{{ 'files.binaryNoCompare' | translate }}</small>
|
||||||
|
</ng-template>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions table-actions--labels table-actions--stack">
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button>
|
||||||
|
<button *ngIf="item.backup_type==='export'" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="info" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item)"></button>
|
||||||
|
<button *ngIf="item.backup_type==='binary' && item.device_type==='routeros'" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="help" icon="pi pi-upload" [label]="'common.restore' | translate" (click)="upload(item)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="deleteOne(item.id)"></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<p-dialog [(visible)]="previewVisible" [modal]="true" [header]="previewTitle || ('files.previewDialogTitle' | translate)" [style]="{ width: 'min(1100px, 92vw)' }" styleClass="preview-dialog">
|
||||||
|
<pre class="code-preview preview-dialog__content">{{ viewedExport }}</pre>
|
||||||
|
</p-dialog>
|
||||||
|
|
||||||
|
<p-dialog [(visible)]="diffVisible" [modal]="true" [header]="'files.diffDialogTitle' | translate" [style]="{ width: 'min(1420px, 96vw)' }" styleClass="preview-dialog preview-dialog--diff">
|
||||||
|
<div class="diff-layout" *ngIf="diffData as diff">
|
||||||
|
<div class="diff-layout__summary">
|
||||||
|
<div>
|
||||||
|
<div class="table-primary">{{ diff.left_file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.compareOlder' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="diff-layout__summary-arrow"><i class="pi pi-arrow-right"></i></div>
|
||||||
|
<div>
|
||||||
|
<div class="table-primary">{{ diff.right_file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.compareNewer' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="diff-stats" *ngIf="diff.stats">
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--added">+{{ diff.stats.added }}</span>
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--removed">-{{ diff.stats.removed }}</span>
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--modified">~{{ diff.stats.modified }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions preview-dialog__actions">
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-align-left" [label]="'files.openPlainDiff' | translate" (click)="diffText = diff.diff_text"></button>
|
||||||
|
<button pButton type="button" severity="help" icon="pi pi-external-link" [label]="'files.openHtmlDiff' | translate" (click)="openHtmlDiff()"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="github-diff" *ngIf="diff.lines?.length; else plainDiffFallback">
|
||||||
|
<div class="github-diff__row" *ngFor="let line of diff.lines" [attr.data-type]="line.type">
|
||||||
|
<div class="github-diff__cell github-diff__cell--left">
|
||||||
|
<span class="github-diff__number">{{ line.left_number || '' }}</span>
|
||||||
|
<pre>{{ line.left_text || ' ' }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="github-diff__cell github-diff__cell--right">
|
||||||
|
<span class="github-diff__number">{{ line.right_number || '' }}</span>
|
||||||
|
<pre>{{ line.right_text || ' ' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #plainDiffFallback>
|
||||||
|
<pre class="code-preview preview-dialog__content">{{ diff.diff_text }}</pre>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</p-dialog>
|
||||||
457
frontend/src/app/features/files/files-page.component.ts
Normal file
457
frontend/src/app/features/files/files-page.component.ts
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { HttpParams, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { DialogModule } from 'primeng/dialog';
|
||||||
|
import { SelectModule } from 'primeng/select';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
import { TableModule } from 'primeng/table';
|
||||||
|
import { TagModule } from 'primeng/tag';
|
||||||
|
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||||
|
import { StatCardComponent } from '../../shared/ui/stat-card.component';
|
||||||
|
|
||||||
|
type DeviceType = 'routeros' | 'switchos';
|
||||||
|
|
||||||
|
interface BackupFile {
|
||||||
|
id: number;
|
||||||
|
router_id: number;
|
||||||
|
router_name?: string;
|
||||||
|
device_type: DeviceType;
|
||||||
|
file_name: string;
|
||||||
|
backup_type: 'export' | 'binary';
|
||||||
|
created_at: string;
|
||||||
|
checksum?: string | null;
|
||||||
|
file_size?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupDiffLine {
|
||||||
|
type: 'context' | 'added' | 'removed' | 'modified';
|
||||||
|
left_number?: number | null;
|
||||||
|
right_number?: number | null;
|
||||||
|
left_text: string;
|
||||||
|
right_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupDiffStats {
|
||||||
|
added: number;
|
||||||
|
removed: number;
|
||||||
|
modified: number;
|
||||||
|
context: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupDiffResponse {
|
||||||
|
left_backup_id: number;
|
||||||
|
right_backup_id: number;
|
||||||
|
left_file_name?: string | null;
|
||||||
|
right_file_name?: string | null;
|
||||||
|
diff_text: string;
|
||||||
|
lines: BackupDiffLine[];
|
||||||
|
stats?: BackupDiffStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, DialogModule, SelectModule, InputTextModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
|
||||||
|
templateUrl: './files-page.component.html'
|
||||||
|
})
|
||||||
|
export class FilesPageComponent implements OnInit {
|
||||||
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
files: BackupFile[] = [];
|
||||||
|
selected: BackupFile[] = [];
|
||||||
|
routers: { id: number; name: string }[] = [];
|
||||||
|
search = '';
|
||||||
|
backupType: 'export' | 'binary' | '' = '';
|
||||||
|
routerId: number | null = null;
|
||||||
|
createdOn = '';
|
||||||
|
sortBy = 'created_at';
|
||||||
|
order: 'asc' | 'desc' = 'desc';
|
||||||
|
diffText = '';
|
||||||
|
viewedExport = '';
|
||||||
|
previewTitle = '';
|
||||||
|
loading = false;
|
||||||
|
bulkBusy = false;
|
||||||
|
compareBusy = false;
|
||||||
|
previewVisible = false;
|
||||||
|
diffVisible = false;
|
||||||
|
compareLeftId: number | null = null;
|
||||||
|
compareRightId: number | null = null;
|
||||||
|
diffData: BackupDiffResponse | null = null;
|
||||||
|
lastCompared: { left: number; right: number } | null = null;
|
||||||
|
|
||||||
|
get typeOptions() {
|
||||||
|
return [
|
||||||
|
{ label: this.ui.instant('files.allTypes'), value: '' },
|
||||||
|
{ label: this.ui.instant('files.exportType'), value: 'export' },
|
||||||
|
{ label: this.ui.instant('files.binaryType'), value: 'binary' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get sortOptions() {
|
||||||
|
return [
|
||||||
|
{ label: this.ui.instant('files.sortNewest'), value: 'created_at' },
|
||||||
|
{ label: this.ui.instant('files.sortName'), value: 'file_name' },
|
||||||
|
{ label: this.ui.instant('files.sortRouter'), value: 'router_name' },
|
||||||
|
{ label: this.ui.instant('files.sortType'), value: 'backup_type' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get orderOptions() {
|
||||||
|
return [
|
||||||
|
{ label: this.ui.instant('common.desc'), value: 'desc' },
|
||||||
|
{ label: this.ui.instant('common.asc'), value: 'asc' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get routerOptions() {
|
||||||
|
return [{ label: this.ui.instant('files.allRouters'), value: null }, ...this.routers.map((r) => ({ label: r.name, value: r.id }))];
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareOptions() {
|
||||||
|
return this.exportFiles.map((item) => ({
|
||||||
|
label: `${item.router_name || item.router_id} · ${item.file_name}`,
|
||||||
|
value: item.id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get exportFiles(): BackupFile[] {
|
||||||
|
return this.files
|
||||||
|
.filter((item) => item.backup_type === 'export')
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedIds(): number[] {
|
||||||
|
return this.selected.map((item) => item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get exportCount(): number {
|
||||||
|
return this.files.filter((item) => item.backup_type === 'export').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get binaryCount(): number {
|
||||||
|
return this.files.filter((item) => item.backup_type === 'binary').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareLeft(): BackupFile | undefined {
|
||||||
|
return this.files.find((item) => item.id === this.compareLeftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareRight(): BackupFile | undefined {
|
||||||
|
return this.files.find((item) => item.id === this.compareRightId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareReady(): boolean {
|
||||||
|
return !!this.compareLeftId && !!this.compareRightId && this.compareLeftId !== this.compareRightId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get compareContextLabel(): string {
|
||||||
|
if (!this.compareReady) {
|
||||||
|
return this.ui.instant('files.compareSelectionHint');
|
||||||
|
}
|
||||||
|
const left = this.compareLeft;
|
||||||
|
const right = this.compareRight;
|
||||||
|
const routerName = left?.router_name || right?.router_name || '';
|
||||||
|
if (left && right && left.router_id === right.router_id) {
|
||||||
|
return this.ui.instant('files.compareReadySameRouter', { router: routerName || left.router_id });
|
||||||
|
}
|
||||||
|
return this.ui.instant('files.compareReadyMixedRouters');
|
||||||
|
}
|
||||||
|
|
||||||
|
compareRole(item: BackupFile): 'left' | 'right' | '' {
|
||||||
|
if (item.id === this.compareLeftId) {
|
||||||
|
return 'left';
|
||||||
|
}
|
||||||
|
if (item.id === this.compareRightId) {
|
||||||
|
return 'right';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.api.http.get<any[]>(`${this.api.baseUrl}/routers`).subscribe((routers) => {
|
||||||
|
this.routers = routers.map((item) => ({ id: item.id, name: item.name }));
|
||||||
|
});
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.loading = true;
|
||||||
|
let params = new HttpParams().set('sort_by', this.sortBy).set('order', this.order);
|
||||||
|
if (this.search.trim()) params = params.set('search', this.search.trim());
|
||||||
|
if (this.backupType) params = params.set('backup_type', this.backupType);
|
||||||
|
if (this.routerId !== null) params = params.set('router_id', String(this.routerId));
|
||||||
|
if (this.createdOn) params = params.set('created_on', this.createdOn);
|
||||||
|
|
||||||
|
this.api.http.get<BackupFile[]>(`${this.api.baseUrl}/backups`, { params }).subscribe({
|
||||||
|
next: (files) => {
|
||||||
|
this.files = files;
|
||||||
|
this.selected = [];
|
||||||
|
if (this.compareLeftId && !this.files.some((item) => item.id === this.compareLeftId)) {
|
||||||
|
this.compareLeftId = null;
|
||||||
|
}
|
||||||
|
if (this.compareRightId && !this.files.some((item) => item.id === this.compareRightId)) {
|
||||||
|
this.compareRightId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters() {
|
||||||
|
this.search = '';
|
||||||
|
this.backupType = '';
|
||||||
|
this.routerId = null;
|
||||||
|
this.createdOn = '';
|
||||||
|
this.sortBy = 'created_at';
|
||||||
|
this.order = 'desc';
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
download(id: number) {
|
||||||
|
this.api.http
|
||||||
|
.get(`${this.api.baseUrl}/backups/${id}/download`, { observe: 'response', responseType: 'blob' })
|
||||||
|
.subscribe((response) => this.openBlob(response, `backup-${id}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
viewExport(item: BackupFile) {
|
||||||
|
this.api.http.get<{ content: string }>(`${this.api.baseUrl}/backups/${item.id}/view`).subscribe((response) => {
|
||||||
|
this.viewedExport = response.content;
|
||||||
|
this.previewTitle = item.file_name;
|
||||||
|
this.ui.clear();
|
||||||
|
this.previewVisible = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEmail(id: number) {
|
||||||
|
this.api.http.post(`${this.api.baseUrl}/backups/${id}/email`, {}).subscribe(() => {
|
||||||
|
this.ui.success('toast.backupSentEmail');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(item: BackupFile) {
|
||||||
|
if (item.device_type !== 'routeros') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.http.post(`${this.api.baseUrl}/backups/router/${item.router_id}/upload/${item.id}`, {}).subscribe(() => {
|
||||||
|
this.ui.success('toast.binaryUploaded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOne(id: number) {
|
||||||
|
const accepted = await this.ui.confirm({
|
||||||
|
messageKey: 'confirm.deleteBackup',
|
||||||
|
acceptKey: 'common.delete'
|
||||||
|
});
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.http.delete(`${this.api.baseUrl}/backups/${id}`).subscribe(() => {
|
||||||
|
this.ui.success('toast.backupDeleted');
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assignCompare(side: 'left' | 'right', item: BackupFile) {
|
||||||
|
if (item.backup_type !== 'export') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (side === 'left') {
|
||||||
|
this.compareLeftId = item.id;
|
||||||
|
} else {
|
||||||
|
this.compareRightId = item.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fillLatestPair() {
|
||||||
|
if (this.exportFiles.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest = this.exportFiles[this.exportFiles.length - 1];
|
||||||
|
const previous = this.exportFiles[this.exportFiles.length - 2];
|
||||||
|
this.compareLeftId = previous.id;
|
||||||
|
this.compareRightId = latest.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
compareClosestForRouter(item: BackupFile) {
|
||||||
|
const candidates = this.exportFiles.filter((entry) => entry.router_id === item.router_id);
|
||||||
|
if (candidates.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetIndex = candidates.findIndex((entry) => entry.id === item.id);
|
||||||
|
const older = candidates[Math.max(0, targetIndex - 1)];
|
||||||
|
const newer = candidates[Math.min(candidates.length - 1, targetIndex + 1)];
|
||||||
|
const left = older && older.id !== item.id ? older : item;
|
||||||
|
const right = newer && newer.id !== item.id ? newer : item;
|
||||||
|
if (left.id === right.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setComparePair(left.id, right.id);
|
||||||
|
this.openStructuredDiff();
|
||||||
|
}
|
||||||
|
|
||||||
|
swapCompare() {
|
||||||
|
const left = this.compareLeftId;
|
||||||
|
this.compareLeftId = this.compareRightId;
|
||||||
|
this.compareRightId = left;
|
||||||
|
}
|
||||||
|
|
||||||
|
openStructuredDiff() {
|
||||||
|
if (!this.compareReady || this.compareBusy || !this.compareLeftId || !this.compareRightId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.compareBusy = true;
|
||||||
|
const [left, right] = this.sortPairByDate(this.compareLeftId, this.compareRightId);
|
||||||
|
this.lastCompared = { left, right };
|
||||||
|
this.api.http.get<BackupDiffResponse>(`${this.api.baseUrl}/backups/${left}/diff/${right}`).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.diffData = response;
|
||||||
|
this.diffText = response.diff_text;
|
||||||
|
this.ui.clear();
|
||||||
|
this.diffVisible = true;
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.compareBusy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openHtmlDiff() {
|
||||||
|
if (!this.lastCompared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.http
|
||||||
|
.get(`${this.api.baseUrl}/backups/${this.lastCompared.left}/diff/${this.lastCompared.right}/html`, { responseType: 'text' })
|
||||||
|
.subscribe((html) => {
|
||||||
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDelete() {
|
||||||
|
if (!this.selectedIds.length || this.bulkBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accepted = await this.ui.confirm({
|
||||||
|
messageKey: 'confirm.deleteSelectedFiles',
|
||||||
|
params: { count: this.selectedIds.length },
|
||||||
|
acceptKey: 'common.delete'
|
||||||
|
});
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.bulkBusy = true;
|
||||||
|
this.api.http.post(`${this.api.baseUrl}/backups/bulk`, { action: 'delete', backup_ids: this.selectedIds }).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.ui.success('toast.selectedBackupsDeleted');
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.bulkBusy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkDownload() {
|
||||||
|
if (!this.selectedIds.length || this.bulkBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.bulkBusy = true;
|
||||||
|
this.api.http
|
||||||
|
.post(`${this.api.baseUrl}/backups/bulk`, { action: 'download', backup_ids: this.selectedIds }, { responseType: 'blob' })
|
||||||
|
.subscribe({
|
||||||
|
next: (blob: Blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'routeros-backups.zip';
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
this.ui.success('toast.archivePrepared');
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.bulkBusy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(value?: number | null): string {
|
||||||
|
if (!value) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = value;
|
||||||
|
let unit = 0;
|
||||||
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeAge(value: string): string {
|
||||||
|
const diff = Date.now() - new Date(value).getTime();
|
||||||
|
const hours = Math.floor(diff / 3_600_000);
|
||||||
|
if (hours < 1) {
|
||||||
|
const minutes = Math.max(1, Math.floor(diff / 60_000));
|
||||||
|
return this.ui.instant('files.minutesAgo', { value: minutes });
|
||||||
|
}
|
||||||
|
if (hours < 24) {
|
||||||
|
return this.ui.instant('files.hoursAgo', { value: hours });
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return this.ui.instant('files.daysAgo', { value: days });
|
||||||
|
}
|
||||||
|
|
||||||
|
checksumShort(value?: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return 'n/a';
|
||||||
|
}
|
||||||
|
return `${value.slice(0, 8)}…${value.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceLabel(item: BackupFile): string {
|
||||||
|
return this.ui.instant(item.device_type === 'switchos' ? 'routers.switchos' : 'routers.routeros');
|
||||||
|
}
|
||||||
|
|
||||||
|
fileExtension(item: BackupFile): string {
|
||||||
|
const parts = item.file_name.split('.');
|
||||||
|
return parts.length > 1 ? `.${parts[parts.length - 1]}` : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
private setComparePair(firstId: number, secondId: number) {
|
||||||
|
const [left, right] = this.sortPairByDate(firstId, secondId);
|
||||||
|
this.compareLeftId = left;
|
||||||
|
this.compareRightId = right;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortPairByDate(firstId: number, secondId: number): [number, number] {
|
||||||
|
const first = this.files.find((item) => item.id === firstId);
|
||||||
|
const second = this.files.find((item) => item.id === secondId);
|
||||||
|
if (!first || !second) {
|
||||||
|
return [firstId, secondId];
|
||||||
|
}
|
||||||
|
return new Date(first.created_at).getTime() <= new Date(second.created_at).getTime() ? [firstId, secondId] : [secondId, firstId];
|
||||||
|
}
|
||||||
|
|
||||||
|
private openBlob(response: HttpResponse<Blob>, fallbackName: string) {
|
||||||
|
const disposition = response.headers.get('content-disposition') || '';
|
||||||
|
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||||
|
const filename = match?.[1] || fallbackName;
|
||||||
|
const url = URL.createObjectURL(response.body || new Blob());
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/src/app/features/logs/logs-page.component.html
Normal file
25
frontend/src/app/features/logs/logs-page.component.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<app-page-header [eyebrow]="'logs.eyebrow' | translate" [title]="'logs.title' | translate" [subtitle]="'logs.subtitle' | translate">
|
||||||
|
<div header-actions class="header-actions-row">
|
||||||
|
<input pInputText type="number" [(ngModel)]="days" [placeholder]="'logs.daysPlaceholder' | translate" class="header-number-input" />
|
||||||
|
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'logs.deleteOlderThan' | translate" [loading]="cleaning" (click)="cleanup()"></button>
|
||||||
|
</div>
|
||||||
|
</app-page-header>
|
||||||
|
|
||||||
|
<div class="inline-summary inline-summary--soft inline-summary--tight">
|
||||||
|
<div class="inline-summary__item">
|
||||||
|
<strong>{{ retentionDays }} {{ 'logs.daysSuffix' | translate }}</strong>
|
||||||
|
<span>{{ 'logs.retentionInfoLabel' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-section-card [title]="'logs.tableTitle' | translate" [subtitle]="'logs.tableSubtitle' | translate">
|
||||||
|
<p-table [value]="logs" responsiveLayout="scroll" styleClass="app-table">
|
||||||
|
<ng-template pTemplate="header"><tr><th>{{ 'logs.timestampColumn' | translate }}</th><th>{{ 'logs.messageColumn' | translate }}</th></tr></ng-template>
|
||||||
|
<ng-template pTemplate="body" let-log>
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.timestamp }}</td>
|
||||||
|
<td><div class="table-primary">{{ log.message }}</div></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</app-section-card>
|
||||||
63
frontend/src/app/features/logs/logs-page.component.ts
Normal file
63
frontend/src/app/features/logs/logs-page.component.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
import { TableModule } from 'primeng/table';
|
||||||
|
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, InputTextModule, TableModule, PageHeaderComponent, SectionCardComponent],
|
||||||
|
templateUrl: './logs-page.component.html'
|
||||||
|
})
|
||||||
|
export class LogsPageComponent implements OnInit {
|
||||||
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
logs: any[] = [];
|
||||||
|
days = 7;
|
||||||
|
retentionDays = 7;
|
||||||
|
cleaning = false;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.load();
|
||||||
|
this.api.http.get<any>(`${this.api.baseUrl}/settings`).subscribe((settings) => {
|
||||||
|
this.retentionDays = Number(settings?.log_retention_days || 7);
|
||||||
|
this.days = this.retentionDays;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.api.http.get<any[]>(`${this.api.baseUrl}/logs`).subscribe((r) => (this.logs = r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup() {
|
||||||
|
if (this.cleaning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accepted = await this.ui.confirm({
|
||||||
|
messageKey: 'confirm.deleteLogsOlderThan',
|
||||||
|
params: { days: this.days },
|
||||||
|
acceptKey: 'common.delete'
|
||||||
|
});
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cleaning = true;
|
||||||
|
this.api.http.delete(`${this.api.baseUrl}/logs/older-than/${this.days}`).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.ui.success('toast.logsDeletedOlderThan', { days: this.days });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.cleaning = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<app-page-header
|
||||||
|
[eyebrow]="'routers.profileEyebrow' | translate"
|
||||||
|
[title]="routerItem?.name || ('routers.detailTitle' | translate)"
|
||||||
|
[subtitle]="subtitle"
|
||||||
|
>
|
||||||
|
<div header-actions class="header-actions-row">
|
||||||
|
<button *ngIf="!isSwitchos" pButton type="button" icon="pi pi-upload" [label]="'routers.exportOne' | translate" [loading]="exporting" (click)="runExport()"></button>
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="(isSwitchos ? 'routers.downloadSwitchBackup' : 'routers.binaryOne') | translate" [loading]="runningBinary" (click)="runBinary()"></button>
|
||||||
|
<button pButton type="button" severity="info" icon="pi pi-wifi" [label]="'routers.testConnection' | translate" [loading]="testing" (click)="testConnection()"></button>
|
||||||
|
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'routers.deleteRouter' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button>
|
||||||
|
</div>
|
||||||
|
</app-page-header>
|
||||||
|
|
||||||
|
<div class="stats-grid compact-grid">
|
||||||
|
<app-stat-card [label]="'routers.deviceType' | translate" [value]="deviceTypeLabel" [hint]="'routers.listSubtitle' | translate" [tag]="'routers.fleetTag' | translate" severity="info" icon="pi pi-sitemap" iconClass="icon-blue"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'routers.binaryLabel' | translate" [value]="binaryBackups.length" [hint]="'routers.binaryLabelHint' | translate" [tag]="'files.binaryType' | translate" severity="warn" icon="pi pi-database" iconClass="icon-amber"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'routers.connectionLabel' | translate" [value]="connectionStateLabel" [hint]="'routers.connectionLabelHint' | translate" [tag]="'routers.probeTag' | translate" severity="info" icon="pi pi-bolt" iconClass="icon-blue"></app-stat-card>
|
||||||
|
<app-stat-card [label]="'routers.sshUser' | translate" [value]="routerItem?.effective_username || '-'" [hint]="'routers.sshUserHint' | translate" [tag]="'routers.accessTag' | translate" severity="secondary" icon="pi pi-user" iconClass="icon-violet"></app-stat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid router-detail-grid router-detail-grid--inspection">
|
||||||
|
<app-section-card [title]="'routers.deviceStatusTitle' | translate" [subtitle]="'routers.deviceStatusSubtitle' | translate">
|
||||||
|
<div class="router-status-panel" *ngIf="connection; else noConnection">
|
||||||
|
<div class="metric-grid-2">
|
||||||
|
<div class="metric-tile"><span>{{ 'routers.connectionStateTitle' | translate }}</span><strong>{{ connection.success ? ('common.ok' | translate) : ('common.failed' | translate) }}</strong></div>
|
||||||
|
<div class="metric-tile"><span>{{ 'routers.lastTestAt' | translate }}</span><strong>{{ connection.tested_at | date:'short' }}</strong></div>
|
||||||
|
<div class="metric-tile"><span>{{ 'routers.hostname' | translate }}</span><strong>{{ connection.hostname }}</strong></div>
|
||||||
|
<div class="metric-tile"><span>{{ 'routers.model' | translate }}</span><strong>{{ connection.model }}</strong></div>
|
||||||
|
<div class="metric-tile"><span>{{ 'routers.version' | translate }}</span><strong>{{ connection.version || 'n/a' }}</strong></div>
|
||||||
|
<div class="metric-tile"><span>{{ 'routers.uptime' | translate }}</span><strong>{{ connection.uptime }}</strong></div>
|
||||||
|
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.httpStatus' | translate }}</span><strong>{{ connection.http_status || '—' }}</strong></div>
|
||||||
|
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.serverHeader' | translate }}</span><strong>{{ connection.server || '—' }}</strong></div>
|
||||||
|
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.authMode' | translate }}</span><strong>{{ connection.auth_mode || '—' }}</strong></div>
|
||||||
|
<div class="metric-tile" *ngIf="isSwitchos"><span>{{ 'routers.backupEndpoint' | translate }}</span><strong>{{ connection.backup_available ? ('routers.backupAvailable' | translate) : ('routers.backupUnavailable' | translate) }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="router-status-error" *ngIf="!connection.success && connection.error">
|
||||||
|
<strong>{{ 'routers.lastError' | translate }}</strong>
|
||||||
|
<span>{{ connection.error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template #noConnection>
|
||||||
|
<div class="empty-state compact-empty">
|
||||||
|
<i class="pi pi-sitemap"></i>
|
||||||
|
<p>{{ 'routers.noConnection' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<div class="router-detail-inspection-stack" *ngIf="!isSwitchos">
|
||||||
|
<app-section-card [title]="'routers.previewTitle' | translate" [subtitle]="'routers.previewSubtitle' | translate">
|
||||||
|
<div class="router-modal-summary" *ngIf="hasPreview; else noPreview">
|
||||||
|
<div>
|
||||||
|
<strong>{{ previewTitle }}</strong>
|
||||||
|
<small>{{ 'routers.previewModalHint' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button pButton type="button" severity="info" icon="pi pi-eye" [label]="'routers.openPreviewModal' | translate" (click)="openPreviewModal()"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template #noPreview>
|
||||||
|
<div class="empty-state compact-empty">
|
||||||
|
<i class="pi pi-eye"></i>
|
||||||
|
<p>{{ 'routers.noPreview' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<app-section-card [title]="'routers.diffTitle' | translate" [subtitle]="'routers.diffSubtitle' | translate">
|
||||||
|
<div class="router-modal-summary" *ngIf="hasDiff && diffData; else noDiff">
|
||||||
|
<div>
|
||||||
|
<strong>{{ diffData.left_file_name }} → {{ diffData.right_file_name }}</strong>
|
||||||
|
<small>{{ 'routers.diffModalHint' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button pButton type="button" severity="help" icon="pi pi-code" [label]="'routers.openDiffModal' | translate" (click)="openDiffModal()"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template #noDiff>
|
||||||
|
<div class="empty-state compact-empty">
|
||||||
|
<i class="pi pi-code"></i>
|
||||||
|
<p>{{ 'routers.noDiff' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</app-section-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid router-detail-grid router-detail-grid--stack" *ngIf="!isSwitchos">
|
||||||
|
<app-section-card [title]="'routers.exportsTableTitle' | translate" [subtitle]="'routers.exportsTableSubtitle' | translate">
|
||||||
|
<p-table [value]="exportBackups" responsiveLayout="scroll" styleClass="app-table">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr><th>{{ 'files.fileColumn' | translate }}</th><th>{{ 'files.createdColumn' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="body" let-item let-i="rowIndex">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ item.file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.exportType' | translate }}</small>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions table-actions--labels table-actions--tight">
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="info" icon="pi pi-eye" [label]="'common.preview' | translate" (click)="viewExport(item.id)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button>
|
||||||
|
<button pButton *ngIf="i > 0" type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="help" icon="pi pi-code" [label]="'common.diff' | translate" (click)="compareToLatest(item.id)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(item.id)"></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</app-section-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid router-detail-grid router-detail-grid--stack">
|
||||||
|
<app-section-card [title]="'routers.binaryTableTitle' | translate" [subtitle]="'routers.binaryTableSubtitle' | translate">
|
||||||
|
<p-table [value]="binaryBackups" responsiveLayout="scroll" styleClass="app-table">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr><th>{{ 'files.fileColumn' | translate }}</th><th>{{ 'files.createdColumn' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="body" let-item>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ item.file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.binaryType' | translate }}</small>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions table-actions--labels table-actions--tight">
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" icon="pi pi-download" [label]="'common.download' | translate" (click)="download(item.id)"></button>
|
||||||
|
<button *ngIf="!isSwitchos" pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="help" icon="pi pi-upload" [label]="'common.restore' | translate" (click)="upload(item.id)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="secondary" icon="pi pi-envelope" [label]="'common.email' | translate" (click)="sendEmail(item.id)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide table-action-btn--compact" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(item.id)"></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</app-section-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p-dialog [(visible)]="previewVisible" [modal]="true" [header]="previewTitle || ('files.previewDialogTitle' | translate)" [style]="{ width: 'min(1100px, 92vw)' }" styleClass="preview-dialog">
|
||||||
|
<pre class="code-preview preview-dialog__content">{{ exportContent }}</pre>
|
||||||
|
</p-dialog>
|
||||||
|
|
||||||
|
<p-dialog [(visible)]="diffVisible" [modal]="true" [header]="'files.diffDialogTitle' | translate" [style]="{ width: 'min(1200px, 94vw)' }" styleClass="preview-dialog preview-dialog--diff">
|
||||||
|
<div class="diff-layout" *ngIf="diffData as diff; else plainDiffOnly">
|
||||||
|
<div class="diff-layout__summary">
|
||||||
|
<div>
|
||||||
|
<div class="table-primary">{{ diff.left_file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.compareOlder' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="diff-layout__summary-arrow"><i class="pi pi-arrow-right"></i></div>
|
||||||
|
<div>
|
||||||
|
<div class="table-primary">{{ diff.right_file_name }}</div>
|
||||||
|
<small class="table-secondary">{{ 'files.compareNewer' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="diff-stats" *ngIf="diff.stats">
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--added">+{{ diff.stats.added }}</span>
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--removed">-{{ diff.stats.removed }}</span>
|
||||||
|
<span class="diff-stats__pill diff-stats__pill--modified">~{{ diff.stats.modified }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="code-preview preview-dialog__content">{{ diff.diff_text }}</pre>
|
||||||
|
</div>
|
||||||
|
<ng-template #plainDiffOnly>
|
||||||
|
<pre class="code-preview preview-dialog__content">{{ diffText }}</pre>
|
||||||
|
</ng-template>
|
||||||
|
</p-dialog>
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { HttpResponse } from '@angular/common/http';
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { DialogModule } from 'primeng/dialog';
|
||||||
|
import { TableModule } from 'primeng/table';
|
||||||
|
import { TagModule } from 'primeng/tag';
|
||||||
|
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||||
|
import { StatCardComponent } from '../../shared/ui/stat-card.component';
|
||||||
|
|
||||||
|
type DeviceType = 'routeros' | 'switchos';
|
||||||
|
|
||||||
|
interface DeviceItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
device_type: DeviceType;
|
||||||
|
effective_username?: string | null;
|
||||||
|
supports_export: boolean;
|
||||||
|
supports_restore_upload: boolean;
|
||||||
|
last_connection_status?: boolean | null;
|
||||||
|
last_connection_tested_at?: string | null;
|
||||||
|
last_connection_error?: string | null;
|
||||||
|
last_connection_hostname?: string | null;
|
||||||
|
last_connection_model?: string | null;
|
||||||
|
last_connection_version?: string | null;
|
||||||
|
last_connection_uptime?: string | null;
|
||||||
|
last_connection_transport?: string | null;
|
||||||
|
last_connection_server?: string | null;
|
||||||
|
last_connection_auth_mode?: string | null;
|
||||||
|
last_connection_http_status?: string | null;
|
||||||
|
last_connection_backup_available?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupItem {
|
||||||
|
id: number;
|
||||||
|
file_name: string;
|
||||||
|
backup_type: 'export' | 'binary';
|
||||||
|
created_at: string;
|
||||||
|
device_type: DeviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionSnapshot {
|
||||||
|
success: boolean;
|
||||||
|
tested_at: string;
|
||||||
|
hostname: string;
|
||||||
|
model: string;
|
||||||
|
version?: string | null;
|
||||||
|
uptime: string;
|
||||||
|
error?: string | null;
|
||||||
|
transport?: string | null;
|
||||||
|
server?: string | null;
|
||||||
|
auth_mode?: string | null;
|
||||||
|
http_status?: string | null;
|
||||||
|
backup_available?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupDiffStats {
|
||||||
|
added: number;
|
||||||
|
removed: number;
|
||||||
|
modified: number;
|
||||||
|
context: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupDiffResponse {
|
||||||
|
left_backup_id: number;
|
||||||
|
right_backup_id: number;
|
||||||
|
left_file_name?: string | null;
|
||||||
|
right_file_name?: string | null;
|
||||||
|
diff_text: string;
|
||||||
|
stats?: BackupDiffStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TranslateModule, ButtonModule, DialogModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
|
||||||
|
templateUrl: './router-detail-page.component.html'
|
||||||
|
})
|
||||||
|
export class RouterDetailPageComponent implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
routerId!: number;
|
||||||
|
routerItem: DeviceItem | null = null;
|
||||||
|
backups: BackupItem[] = [];
|
||||||
|
connection: ConnectionSnapshot | null = null;
|
||||||
|
exportContent = '';
|
||||||
|
diffText = '';
|
||||||
|
previewTitle = '';
|
||||||
|
previewVisible = false;
|
||||||
|
diffVisible = false;
|
||||||
|
diffData: BackupDiffResponse | null = null;
|
||||||
|
exporting = false;
|
||||||
|
runningBinary = false;
|
||||||
|
testing = false;
|
||||||
|
deletingRouter = false;
|
||||||
|
|
||||||
|
get isSwitchos(): boolean {
|
||||||
|
return this.routerItem?.device_type === 'switchos';
|
||||||
|
}
|
||||||
|
|
||||||
|
get exportBackups(): BackupItem[] {
|
||||||
|
return this.backups.filter((item) => item.backup_type === 'export');
|
||||||
|
}
|
||||||
|
|
||||||
|
get binaryBackups(): BackupItem[] {
|
||||||
|
return this.backups.filter((item) => item.backup_type === 'binary');
|
||||||
|
}
|
||||||
|
|
||||||
|
get connectionStateLabel(): string {
|
||||||
|
if (!this.connection) {
|
||||||
|
return this.ui.instant('common.idle');
|
||||||
|
}
|
||||||
|
return this.connection.success ? this.ui.instant('common.ok') : this.ui.instant('common.failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasPreview(): boolean {
|
||||||
|
return !!this.exportContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasDiff(): boolean {
|
||||||
|
return !!this.diffText;
|
||||||
|
}
|
||||||
|
|
||||||
|
get subtitle(): string {
|
||||||
|
if (!this.routerItem) {
|
||||||
|
return this.ui.instant('routers.detailSubtitle');
|
||||||
|
}
|
||||||
|
const suffix = this.routerItem.effective_username ? ` · ${this.routerItem.effective_username}` : '';
|
||||||
|
return `${this.routerItem.host}:${this.routerItem.port}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceTypeLabel(): string {
|
||||||
|
return this.ui.instant(this.isSwitchos ? 'routers.switchos' : 'routers.routeros');
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.routerId = Number(this.route.snapshot.paramMap.get('id'));
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.api.http.get<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem) => {
|
||||||
|
this.routerItem = routerItem;
|
||||||
|
this.connection = this.mapStoredConnection(routerItem);
|
||||||
|
});
|
||||||
|
this.api.http.get<BackupItem[]>(`${this.api.baseUrl}/backups/router/${this.routerId}`).subscribe((r) => (this.backups = r));
|
||||||
|
}
|
||||||
|
|
||||||
|
runExport() {
|
||||||
|
if (this.exporting || this.isSwitchos) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.exporting = true;
|
||||||
|
this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/export`, {}).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.ui.success('toast.exportCreated');
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.exporting = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
runBinary() {
|
||||||
|
if (this.runningBinary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.runningBinary = true;
|
||||||
|
this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/binary`, {}).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.ui.success('toast.binaryCreated');
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.runningBinary = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testConnection() {
|
||||||
|
if (this.testing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.testing = true;
|
||||||
|
this.api.http.get<ConnectionSnapshot>(`${this.api.baseUrl}/routers/${this.routerId}/test-connection`).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.connection = result;
|
||||||
|
this.syncStoredConnection(result);
|
||||||
|
if (result.success) {
|
||||||
|
this.ui.success('toast.connectionSuccessful');
|
||||||
|
} else {
|
||||||
|
this.ui.error('toast.connectionFailed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.testing = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
compareToLatest(id: number) {
|
||||||
|
const latest = this.exportBackups[0];
|
||||||
|
if (!latest || latest.id === id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.http.get<BackupDiffResponse>(`${this.api.baseUrl}/backups/${id}/diff/${latest.id}`).subscribe((response) => {
|
||||||
|
this.diffData = response;
|
||||||
|
this.diffText = response.diff_text;
|
||||||
|
this.ui.clear();
|
||||||
|
this.diffVisible = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
const accepted = await this.ui.confirm({ messageKey: 'confirm.deleteBackup', acceptKey: 'common.delete' });
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.http.delete(`${this.api.baseUrl}/backups/${id}`).subscribe(() => {
|
||||||
|
this.ui.success('toast.backupDeleted');
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(id: number) {
|
||||||
|
if (this.isSwitchos) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.http.post(`${this.api.baseUrl}/backups/router/${this.routerId}/upload/${id}`, {}).subscribe(() => {
|
||||||
|
this.ui.success('toast.binaryUploaded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRouter() {
|
||||||
|
if (this.deletingRouter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accepted = await this.ui.confirm({ messageKey: 'confirm.deleteRouterWithFiles', acceptKey: 'common.delete' });
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deletingRouter = true;
|
||||||
|
this.api.http.delete(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe({
|
||||||
|
next: () => this.router.navigate(['/devices']),
|
||||||
|
complete: () => {
|
||||||
|
this.deletingRouter = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
download(id: number) {
|
||||||
|
this.api.http
|
||||||
|
.get(`${this.api.baseUrl}/backups/${id}/download`, { observe: 'response', responseType: 'blob' })
|
||||||
|
.subscribe((response) => this.openBlob(response, `backup-${id}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
viewExport(id: number) {
|
||||||
|
const backup = this.exportBackups.find((item) => item.id === id);
|
||||||
|
this.api.http.get<{ content: string }>(`${this.api.baseUrl}/backups/${id}/view`).subscribe((r) => {
|
||||||
|
this.exportContent = r.content;
|
||||||
|
this.previewTitle = backup?.file_name || this.ui.instant('routers.previewTitle');
|
||||||
|
this.ui.clear();
|
||||||
|
this.previewVisible = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEmail(id: number) {
|
||||||
|
this.api.http.post(`${this.api.baseUrl}/backups/${id}/email`, {}).subscribe(() => {
|
||||||
|
this.ui.success('toast.backupSentEmail');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openPreviewModal() {
|
||||||
|
this.ui.clear();
|
||||||
|
this.previewVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
openDiffModal() {
|
||||||
|
this.ui.clear();
|
||||||
|
this.diffVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStoredConnection(routerItem: DeviceItem): ConnectionSnapshot | null {
|
||||||
|
if (!routerItem?.last_connection_tested_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: Boolean(routerItem.last_connection_status),
|
||||||
|
tested_at: routerItem.last_connection_tested_at,
|
||||||
|
hostname: routerItem.last_connection_hostname || routerItem.name,
|
||||||
|
model: routerItem.last_connection_model || 'Unknown',
|
||||||
|
version: routerItem.last_connection_version,
|
||||||
|
uptime: routerItem.last_connection_uptime || 'Unknown',
|
||||||
|
error: routerItem.last_connection_error || null,
|
||||||
|
transport: routerItem.last_connection_transport || null,
|
||||||
|
server: routerItem.last_connection_server || null,
|
||||||
|
auth_mode: routerItem.last_connection_auth_mode || null,
|
||||||
|
http_status: routerItem.last_connection_http_status || null,
|
||||||
|
backup_available: routerItem.last_connection_backup_available ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncStoredConnection(result: ConnectionSnapshot) {
|
||||||
|
if (!this.routerItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.routerItem = {
|
||||||
|
...this.routerItem,
|
||||||
|
last_connection_status: result.success,
|
||||||
|
last_connection_tested_at: result.tested_at,
|
||||||
|
last_connection_hostname: result.hostname,
|
||||||
|
last_connection_model: result.model,
|
||||||
|
last_connection_version: result.version,
|
||||||
|
last_connection_uptime: result.uptime,
|
||||||
|
last_connection_error: result.error,
|
||||||
|
last_connection_transport: result.transport,
|
||||||
|
last_connection_server: result.server,
|
||||||
|
last_connection_auth_mode: result.auth_mode,
|
||||||
|
last_connection_http_status: result.http_status,
|
||||||
|
last_connection_backup_available: result.backup_available
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private openBlob(response: HttpResponse<Blob>, fallbackName: string) {
|
||||||
|
const disposition = response.headers.get('content-disposition') || '';
|
||||||
|
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||||
|
const filename = match?.[1] || fallbackName;
|
||||||
|
const url = URL.createObjectURL(response.body || new Blob());
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
frontend/src/app/features/routers/routers-page.component.html
Normal file
156
frontend/src/app/features/routers/routers-page.component.html
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<app-page-header [eyebrow]="'routers.eyebrow' | translate" [title]="'routers.title' | translate" [subtitle]="'routers.subtitle' | translate">
|
||||||
|
<div header-actions class="header-actions-row">
|
||||||
|
<button pButton type="button" icon="pi pi-plus" (click)="openCreate()" [label]="'routers.add' | translate"></button>
|
||||||
|
</div>
|
||||||
|
</app-page-header>
|
||||||
|
|
||||||
|
<div class="inline-summary inline-summary--soft">
|
||||||
|
<div class="inline-summary__item">
|
||||||
|
<strong>{{ routers.length }}</strong>
|
||||||
|
<span>{{ 'routers.registeredDevices' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-summary__divider"></div>
|
||||||
|
<div class="inline-summary__item">
|
||||||
|
<strong>{{ routerOsCount }}</strong>
|
||||||
|
<span>{{ 'routers.routeros' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-summary__divider"></div>
|
||||||
|
<div class="inline-summary__item">
|
||||||
|
<strong>{{ switchOsCount }}</strong>
|
||||||
|
<span>{{ 'routers.switchos' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-section-card [title]="'routers.listTitle' | translate" [subtitle]="'routers.listSubtitle' | translate">
|
||||||
|
<p-table [value]="routers" responsiveLayout="scroll" styleClass="app-table">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr><th>{{ 'routers.name' | translate }}</th><th>{{ 'routers.endpoint' | translate }}</th><th>{{ 'routers.access' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="body" let-routerItem>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ routerItem.name }}</div>
|
||||||
|
<small class="table-secondary">{{ deviceTypeLabel(routerItem) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-primary">{{ routerItem.host }}:{{ routerItem.port }}</div>
|
||||||
|
<small class="table-secondary">{{ accessUser(routerItem) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="inline-tags">
|
||||||
|
<p-tag [value]="primaryAccessTag(routerItem).value" [severity]="primaryAccessTag(routerItem).severity"></p-tag>
|
||||||
|
<p-tag [value]="secondaryAccessTag(routerItem).value" [severity]="secondaryAccessTag(routerItem).severity"></p-tag>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions table-actions--labels">
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-arrow-right" [label]="'common.open' | translate" (click)="open(routerItem.id)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="secondary" icon="pi pi-pencil" [label]="'common.edit' | translate" (click)="edit(routerItem)"></button>
|
||||||
|
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" severity="danger" icon="pi pi-trash" [label]="'common.delete' | translate" (click)="remove(routerItem.id)"></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<p-dialog [(visible)]="visible" [modal]="true" [draggable]="false" [resizable]="false" [style]="{ width: 'min(760px, 96vw)' }" styleClass="router-dialog">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<div class="router-dialog-header">
|
||||||
|
<div class="router-dialog-header__icon">
|
||||||
|
<i class="pi" [ngClass]="selectedDeviceType === 'switchos' ? 'pi-sitemap' : 'pi-server'"></i>
|
||||||
|
</div>
|
||||||
|
<div class="router-dialog-header__text">
|
||||||
|
<div class="router-dialog-header__eyebrow">
|
||||||
|
{{ 'routers.deviceType' | translate }} · {{ selectedDeviceType === 'switchos' ? ('routers.switchos' | translate) : ('routers.routeros' | translate) }}
|
||||||
|
</div>
|
||||||
|
<div class="router-dialog-header__title">{{ dialogTitle }}</div>
|
||||||
|
<small>
|
||||||
|
{{
|
||||||
|
selectedDeviceType === 'switchos'
|
||||||
|
? ('routers.switchDialogSubtitle' | translate)
|
||||||
|
: ('routers.routerDialogSubtitle' | translate)
|
||||||
|
}}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="save()" class="router-dialog-form">
|
||||||
|
<section class="router-dialog-panel">
|
||||||
|
<div class="router-dialog-panel__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'routers.connectionSectionTitle' | translate }}</strong>
|
||||||
|
<p>{{ 'routers.connectionSectionHint' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="router-dialog-pill">
|
||||||
|
<i class="pi" [ngClass]="selectedDeviceType === 'switchos' ? 'pi-globe' : 'pi-shield'"></i>
|
||||||
|
{{ selectedDeviceType === 'switchos' ? 'HTTP' : 'SSH' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid-2 router-dialog-grid">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'routers.name' | translate }}</label>
|
||||||
|
<input pInputText formControlName="name" placeholder="core-router-waw" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'routers.deviceType' | translate }}</label>
|
||||||
|
<p-select [options]="deviceTypeOptions" formControlName="device_type" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'routers.host' | translate }}</label>
|
||||||
|
<input pInputText formControlName="host" placeholder="10.0.0.1" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'routers.port' | translate }}</label>
|
||||||
|
<input pInputText type="number" formControlName="port" [placeholder]="selectedDeviceType === 'switchos' ? '80' : '22'" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="router-dialog-panel">
|
||||||
|
<div class="router-dialog-panel__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'routers.credentialsSectionTitle' | translate }}</strong>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
selectedDeviceType === 'switchos'
|
||||||
|
? ('routers.switchDialogSubtitle' | translate)
|
||||||
|
: ('routers.routerDialogSubtitle' | translate)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="router-dialog-pill">
|
||||||
|
<i class="pi pi-key"></i>
|
||||||
|
{{ selectedDeviceType === 'switchos' ? ('routers.defaultCredentials' | translate) : 'SSH' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid-2 router-dialog-grid">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'routers.sshUser' | translate }}</label>
|
||||||
|
<input pInputText formControlName="ssh_user" [placeholder]="selectedDeviceType === 'switchos' ? ('routers.switchUserPlaceholder' | translate) : 'admin'" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'routers.sshPassword' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="ssh_password" [placeholder]="selectedDeviceType === 'switchos' ? ('routers.switchPasswordPlaceholder' | translate) : ('routers.optionalPassword' | translate)" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field form-field--full" *ngIf="selectedDeviceType === 'routeros'">
|
||||||
|
<label>{{ 'routers.sshPrivateKey' | translate }}</label>
|
||||||
|
<textarea pTextarea formControlName="ssh_key" rows="8" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="router-dialog-note" *ngIf="selectedDeviceType === 'switchos'">
|
||||||
|
<i class="pi pi-info-circle"></i>
|
||||||
|
<span>{{ 'routers.switchDefaultsHint' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="dialog-actions router-dialog-actions">
|
||||||
|
<button pButton type="button" severity="secondary" [label]="'common.cancel' | translate" (click)="visible=false"></button>
|
||||||
|
<button pButton type="submit" [disabled]="form.invalid || saving" [loading]="saving" [label]="'routers.saveRouter' | translate"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</p-dialog>
|
||||||
217
frontend/src/app/features/routers/routers-page.component.ts
Normal file
217
frontend/src/app/features/routers/routers-page.component.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { DialogModule } from 'primeng/dialog';
|
||||||
|
import { SelectModule } from 'primeng/select';
|
||||||
|
import { TextareaModule } from 'primeng/textarea';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
import { TableModule } from 'primeng/table';
|
||||||
|
import { TagModule } from 'primeng/tag';
|
||||||
|
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||||
|
|
||||||
|
type DeviceType = 'routeros' | 'switchos';
|
||||||
|
|
||||||
|
interface RouterItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
device_type: DeviceType;
|
||||||
|
ssh_user?: string | null;
|
||||||
|
ssh_password?: string | null;
|
||||||
|
ssh_key?: string | null;
|
||||||
|
effective_username?: string | null;
|
||||||
|
uses_global_ssh_key?: boolean;
|
||||||
|
has_effective_ssh_key?: boolean;
|
||||||
|
uses_global_switchos_credentials?: boolean;
|
||||||
|
has_effective_password?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
TranslateModule,
|
||||||
|
ButtonModule,
|
||||||
|
DialogModule,
|
||||||
|
SelectModule,
|
||||||
|
InputTextModule,
|
||||||
|
TextareaModule,
|
||||||
|
TableModule,
|
||||||
|
TagModule,
|
||||||
|
PageHeaderComponent,
|
||||||
|
SectionCardComponent
|
||||||
|
],
|
||||||
|
templateUrl: './routers-page.component.html'
|
||||||
|
})
|
||||||
|
export class RoutersPageComponent implements OnInit {
|
||||||
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
visible = false;
|
||||||
|
editingId: number | null = null;
|
||||||
|
saving = false;
|
||||||
|
routers: RouterItem[] = [];
|
||||||
|
readonly deviceTypeOptions = [
|
||||||
|
{ label: 'RouterOS', value: 'routeros' },
|
||||||
|
{ label: 'SwitchOS', value: 'switchos' }
|
||||||
|
];
|
||||||
|
readonly form = this.fb.nonNullable.group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
device_type: ['routeros' as DeviceType, Validators.required],
|
||||||
|
host: ['', Validators.required],
|
||||||
|
port: [22, Validators.required],
|
||||||
|
ssh_user: ['admin'],
|
||||||
|
ssh_password: '',
|
||||||
|
ssh_key: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
get dialogTitle(): string {
|
||||||
|
return this.ui.instant(this.editingId ? 'routers.editDialogTitle' : 'routers.createDialogTitle');
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedDeviceType(): DeviceType {
|
||||||
|
return this.form.controls.device_type.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get routerOsCount(): number {
|
||||||
|
return this.routers.filter((item) => item.device_type === 'routeros').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get switchOsCount(): number {
|
||||||
|
return this.routers.filter((item) => item.device_type === 'switchos').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.form.controls.device_type.valueChanges.subscribe((deviceType) => {
|
||||||
|
this.applyDeviceDefaults((deviceType || 'routeros') as DeviceType);
|
||||||
|
});
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.api.http.get<RouterItem[]>(`${this.api.baseUrl}/routers`).subscribe((r) => (this.routers = r));
|
||||||
|
}
|
||||||
|
|
||||||
|
openCreate() {
|
||||||
|
this.editingId = null;
|
||||||
|
this.form.reset({ name: '', device_type: 'routeros', host: '', port: 22, ssh_user: 'admin', ssh_password: '', ssh_key: '' });
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(item: RouterItem) {
|
||||||
|
this.editingId = item.id;
|
||||||
|
this.form.reset({
|
||||||
|
name: item.name,
|
||||||
|
device_type: item.device_type,
|
||||||
|
host: item.host,
|
||||||
|
port: item.port,
|
||||||
|
ssh_user: item.ssh_user ?? '',
|
||||||
|
ssh_password: item.ssh_password ?? '',
|
||||||
|
ssh_key: item.ssh_key ?? ''
|
||||||
|
});
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.form.invalid || this.saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saving = true;
|
||||||
|
const payload = this.form.getRawValue();
|
||||||
|
if (payload.device_type === 'switchos') {
|
||||||
|
payload.ssh_key = '';
|
||||||
|
}
|
||||||
|
const request$ = this.editingId
|
||||||
|
? this.api.http.put(`${this.api.baseUrl}/routers/${this.editingId}`, payload)
|
||||||
|
: this.api.http.post(`${this.api.baseUrl}/routers`, payload);
|
||||||
|
|
||||||
|
request$.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.ui.success(this.editingId ? 'toast.routerUpdated' : 'toast.routerCreated');
|
||||||
|
this.visible = false;
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
const accepted = await this.ui.confirm({
|
||||||
|
messageKey: 'confirm.deleteRouterWithFiles',
|
||||||
|
acceptKey: 'common.delete'
|
||||||
|
});
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.http.delete(`${this.api.baseUrl}/routers/${id}`).subscribe(() => {
|
||||||
|
this.ui.success('toast.routerDeleted');
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open(id: number) {
|
||||||
|
this.router.navigate(['/devices', id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceTypeLabel(item: RouterItem): string {
|
||||||
|
return this.ui.instant(item.device_type === 'switchos' ? 'routers.switchos' : 'routers.routeros');
|
||||||
|
}
|
||||||
|
|
||||||
|
accessUser(item: RouterItem): string {
|
||||||
|
return item.effective_username || item.ssh_user || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warn' | 'secondary' | 'info' } {
|
||||||
|
if (item.device_type === 'switchos') {
|
||||||
|
if (item.uses_global_switchos_credentials) {
|
||||||
|
return { value: this.ui.instant('routers.defaultCredentials'), severity: 'info' };
|
||||||
|
}
|
||||||
|
if (item.has_effective_password) {
|
||||||
|
return { value: this.ui.instant('routers.localCredentials'), severity: 'success' };
|
||||||
|
}
|
||||||
|
return { value: this.ui.instant('routers.noCredentials'), severity: 'secondary' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: item.ssh_password ? this.ui.instant('routers.passwordMode') : this.ui.instant('routers.noPassword'),
|
||||||
|
severity: item.ssh_password ? 'warn' : 'secondary'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
secondaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warn' | 'secondary' | 'info' } {
|
||||||
|
if (item.device_type === 'switchos') {
|
||||||
|
return {
|
||||||
|
value: item.has_effective_password ? this.ui.instant('routers.passwordMode') : this.ui.instant('routers.noPassword'),
|
||||||
|
severity: item.has_effective_password ? 'warn' : 'secondary'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: item.has_effective_ssh_key
|
||||||
|
? this.ui.instant(item.uses_global_ssh_key ? 'routers.globalKeyMode' : 'routers.keyMode')
|
||||||
|
: this.ui.instant('routers.noKey'),
|
||||||
|
severity: item.has_effective_ssh_key ? 'success' : 'secondary'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDeviceDefaults(deviceType: DeviceType) {
|
||||||
|
if (deviceType === 'switchos') {
|
||||||
|
this.form.patchValue({ port: 80, ssh_key: '', ssh_user: this.form.controls.ssh_user.value || '' }, { emitEvent: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.form.patchValue({ port: 22, ssh_user: this.form.controls.ssh_user.value || 'admin' }, { emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
327
frontend/src/app/features/settings/settings-page.component.html
Normal file
327
frontend/src/app/features/settings/settings-page.component.html
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
<app-page-header [eyebrow]="'settings.eyebrow' | translate" [title]="'settings.title' | translate" [subtitle]="'settings.subtitle' | translate">
|
||||||
|
<div header-actions class="header-actions-row">
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-envelope" [label]="'settings.testEmail' | translate" [loading]="testingEmail" (click)="testEmail()"></button>
|
||||||
|
<button pButton type="button" severity="help" icon="pi pi-send" [label]="'settings.testPushover' | translate" [loading]="testingPushover" (click)="testPushover()"></button>
|
||||||
|
</div>
|
||||||
|
</app-page-header>
|
||||||
|
|
||||||
|
<div class="settings-status-grid" *ngIf="schedulerStatus as status">
|
||||||
|
<div class="settings-status-card" *ngFor="let job of status.jobs" [attr.data-valid]="job.valid">
|
||||||
|
<div class="settings-status-card__header">
|
||||||
|
<strong>{{ job.label | translate }}</strong>
|
||||||
|
<p-tag [value]="job.enabled ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="job.enabled ? 'success' : 'secondary'"></p-tag>
|
||||||
|
</div>
|
||||||
|
<div class="settings-status-card__body">
|
||||||
|
<div class="settings-status-card__description">{{ job.description | translate: job.description_params }}</div>
|
||||||
|
<small>{{ job.valid ? ((job.next_runs[0] | date:'short') || ('settings.noNextRun' | translate)) : job.error }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="save()" class="settings-page-shell">
|
||||||
|
<div class="settings-page-columns">
|
||||||
|
<div class="settings-page-main">
|
||||||
|
<details class="settings-collapse">
|
||||||
|
<summary>
|
||||||
|
<span>{{ 'settings.automationTitle' | translate }}</span>
|
||||||
|
<small>{{ 'settings.automationSubtitle' | translate }}</small>
|
||||||
|
</summary>
|
||||||
|
<div class="settings-collapse__body">
|
||||||
|
<div class="settings-automation-intro">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.automationPlannerTitle' | translate }}</strong>
|
||||||
|
<p>{{ 'settings.automationPlannerSubtitle' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<p-tag [value]="'settings.automationPlannerTag' | translate" severity="info"></p-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-scheduler-stack">
|
||||||
|
<div class="scheduler-card">
|
||||||
|
<div class="scheduler-card__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.exportScheduleTitle' | translate }}</strong>
|
||||||
|
<small>{{ scheduleSummary(scheduleEditors.export) }}</small>
|
||||||
|
</div>
|
||||||
|
<p-tag [value]="scheduleEnabled(scheduleEditors.export) ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="scheduleSeverity(scheduleEditors.export)"></p-tag>
|
||||||
|
</div>
|
||||||
|
<div class="scheduler-card__hint">{{ 'settings.exportPlannerHint' | translate }}</div>
|
||||||
|
<div class="scheduler-card__grid">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.scheduleMode' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.export.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
<span class="form-field" *ngIf="scheduleEditors.export.mode !== 'custom' && scheduleEditors.export.mode !== 'disabled'">
|
||||||
|
<label>{{ 'settings.scheduleTime' | translate }}</label>
|
||||||
|
<div class="time-picker">
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.export.hour" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.export)" />
|
||||||
|
<span>:</span>
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.export.minute" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.export)" />
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span class="form-field" *ngIf="scheduleEditors.export.mode === 'weekly'">
|
||||||
|
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.export.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
<span class="form-field form-field--full" *ngIf="scheduleEditors.export.mode === 'custom'">
|
||||||
|
<label>{{ 'settings.exportCron' | translate }}</label>
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.export.cron" [ngModelOptions]="{ standalone: true }" placeholder="0 2 * * *" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scheduler-card">
|
||||||
|
<div class="scheduler-card__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.binaryScheduleTitle' | translate }}</strong>
|
||||||
|
<small>{{ scheduleSummary(scheduleEditors.binary) }}</small>
|
||||||
|
</div>
|
||||||
|
<p-tag [value]="scheduleEnabled(scheduleEditors.binary) ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="scheduleSeverity(scheduleEditors.binary)"></p-tag>
|
||||||
|
</div>
|
||||||
|
<div class="scheduler-card__hint">{{ 'settings.binaryPlannerHint' | translate }}</div>
|
||||||
|
<div class="scheduler-card__grid">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.scheduleMode' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.binary.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
<span class="form-field" *ngIf="scheduleEditors.binary.mode !== 'custom' && scheduleEditors.binary.mode !== 'disabled'">
|
||||||
|
<label>{{ 'settings.scheduleTime' | translate }}</label>
|
||||||
|
<div class="time-picker">
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.binary.hour" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.binary)" />
|
||||||
|
<span>:</span>
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.binary.minute" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.binary)" />
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span class="form-field" *ngIf="scheduleEditors.binary.mode === 'weekly'">
|
||||||
|
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.binary.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
<span class="form-field form-field--full" *ngIf="scheduleEditors.binary.mode === 'custom'">
|
||||||
|
<label>{{ 'settings.binaryCron' | translate }}</label>
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.binary.cron" [ngModelOptions]="{ standalone: true }" placeholder="0 3 * * 0" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scheduler-card">
|
||||||
|
<div class="scheduler-card__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.retentionTitle' | translate }}</strong>
|
||||||
|
<small>{{ scheduleSummary(scheduleEditors.retention) }}</small>
|
||||||
|
</div>
|
||||||
|
<p-tag [value]="scheduleEnabled(scheduleEditors.retention) ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="scheduleSeverity(scheduleEditors.retention)"></p-tag>
|
||||||
|
</div>
|
||||||
|
<div class="scheduler-card__hint">{{ 'settings.retentionPlannerHint' | translate }}</div>
|
||||||
|
<div class="scheduler-card__grid">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.backupRetentionDays' | translate }}</label>
|
||||||
|
<input pInputText type="number" formControlName="backup_retention_days" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.logRetentionDays' | translate }}</label>
|
||||||
|
<input pInputText type="number" formControlName="log_retention_days" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.scheduleMode' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.retention.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
<span class="form-field" *ngIf="scheduleEditors.retention.mode !== 'custom' && scheduleEditors.retention.mode !== 'disabled'">
|
||||||
|
<label>{{ 'settings.scheduleTime' | translate }}</label>
|
||||||
|
<div class="time-picker">
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.retention.hour" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.retention)" />
|
||||||
|
<span>:</span>
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.retention.minute" [ngModelOptions]="{ standalone: true }" (blur)="normalizeTime(scheduleEditors.retention)" />
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span class="form-field" *ngIf="scheduleEditors.retention.mode === 'weekly'">
|
||||||
|
<label>{{ 'settings.scheduleWeekday' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.retention.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-select>
|
||||||
|
</span>
|
||||||
|
<span class="form-field form-field--full" *ngIf="scheduleEditors.retention.mode === 'custom'">
|
||||||
|
<label>{{ 'settings.retentionCron' | translate }}</label>
|
||||||
|
<input pInputText [(ngModel)]="scheduleEditors.retention.cron" [ngModelOptions]="{ standalone: true }" placeholder="0 4 * * *" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scheduler-card scheduler-card--subtle">
|
||||||
|
<div class="scheduler-card__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.connectionTestsTitle' | translate }}</strong>
|
||||||
|
<small>{{ connectionTestSummary() }}</small>
|
||||||
|
</div>
|
||||||
|
<p-tag [value]="form.controls.connection_test_interval_minutes.value > 0 ? ('settings.statusEnabled' | translate) : ('settings.statusDisabled' | translate)" [severity]="connectionTestSeverity()"></p-tag>
|
||||||
|
</div>
|
||||||
|
<div class="scheduler-card__hint">{{ 'settings.connectionTestsHint' | translate }}</div>
|
||||||
|
<div class="scheduler-card__grid scheduler-card__grid--compact">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.connectionTestIntervalMinutes' | translate }}</label>
|
||||||
|
<input pInputText type="number" min="0" formControlName="connection_test_interval_minutes" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="settings-collapse" open>
|
||||||
|
<summary>
|
||||||
|
<span>{{ 'settings.interfaceTitle' | translate }}</span>
|
||||||
|
<small>{{ 'settings.interfaceSubtitle' | translate }}</small>
|
||||||
|
</summary>
|
||||||
|
<div class="settings-collapse__body">
|
||||||
|
<div class="settings-interface-intro">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.interfacePreferencesTitle' | translate }}</strong>
|
||||||
|
<p>{{ 'settings.interfacePreferencesHint' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<p-tag [value]="'settings.interfacePreferencesTag' | translate" severity="info"></p-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'topbar.languageSelector' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" formControlName="preferred_language" [options]="languageOptions" optionLabel="label" optionValue="value" (onChange)="previewLanguage($event.value)"></p-select>
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.fontFamily' | translate }}</label>
|
||||||
|
<p-select [appendTo]="'body'" formControlName="preferred_font" [options]="fontOptions" optionLabel="label" optionValue="value" (onChange)="previewFont()"></p-select>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="settings-collapse">
|
||||||
|
<summary>
|
||||||
|
<span>{{ 'settings.notificationsTitle' | translate }}</span>
|
||||||
|
<small>{{ 'settings.notificationsSubtitle' | translate }}</small>
|
||||||
|
</summary>
|
||||||
|
<div class="settings-collapse__body">
|
||||||
|
<div class="settings-toggle-grid">
|
||||||
|
<div class="settings-toggle">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.smtpEnabled' | translate }}</strong>
|
||||||
|
<small>{{ 'settings.smtpEnabledHint' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="choice-toggle" role="group" [attr.aria-label]="'settings.smtpEnabled' | translate">
|
||||||
|
<button type="button" class="choice-toggle__btn" [class.is-active]="form.controls.smtp_notifications_enabled.value" (click)="setBooleanSetting('smtp_notifications_enabled', true)">{{ 'common.enabled' | translate }}</button>
|
||||||
|
<button type="button" class="choice-toggle__btn" [class.is-active]="!form.controls.smtp_notifications_enabled.value" (click)="setBooleanSetting('smtp_notifications_enabled', false)">{{ 'common.disabled' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-toggle">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.failuresOnly' | translate }}</strong>
|
||||||
|
<small>{{ 'settings.failuresOnlyHint' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="choice-toggle" role="group" [attr.aria-label]="'settings.failuresOnly' | translate">
|
||||||
|
<button type="button" class="choice-toggle__btn" [class.is-active]="form.controls.notify_failures_only.value" (click)="setBooleanSetting('notify_failures_only', true)">{{ 'common.enabled' | translate }}</button>
|
||||||
|
<button type="button" class="choice-toggle__btn" [class.is-active]="!form.controls.notify_failures_only.value" (click)="setBooleanSetting('notify_failures_only', false)">{{ 'common.disabled' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.smtpHost' | translate }}</label>
|
||||||
|
<input pInputText formControlName="smtp_host" placeholder="smtp.example.com" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.smtpPort' | translate }}</label>
|
||||||
|
<input pInputText type="number" formControlName="smtp_port" placeholder="587" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.smtpLogin' | translate }}</label>
|
||||||
|
<input pInputText formControlName="smtp_login" placeholder="alerts@example.com" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.smtpPassword' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="smtp_password" placeholder="••••••••" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field form-field--full">
|
||||||
|
<label>{{ 'settings.recipientEmail' | translate }}</label>
|
||||||
|
<input pInputText formControlName="recipient_email" placeholder="netops@example.com" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.pushoverToken' | translate }}</label>
|
||||||
|
<input pInputText formControlName="pushover_token" [placeholder]="'settings.pushoverTokenPlaceholder' | translate" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.pushoverUserKey' | translate }}</label>
|
||||||
|
<input pInputText formControlName="pushover_userkey" [placeholder]="'settings.pushoverUserKeyPlaceholder' | translate" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-page-side">
|
||||||
|
<details class="settings-collapse settings-collapse--sticky" open>
|
||||||
|
<summary>
|
||||||
|
<span>{{ 'settings.sshDefaultsTitle' | translate }}</span>
|
||||||
|
<small>{{ 'settings.sshDefaultsSubtitle' | translate }}</small>
|
||||||
|
</summary>
|
||||||
|
<div class="settings-collapse__body">
|
||||||
|
<div class="settings-ssh-panel">
|
||||||
|
<div class="settings-ssh-panel__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.globalSshPrivateKey' | translate }}</strong>
|
||||||
|
<p>{{ 'settings.sshKeyHelper' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<p-tag *ngIf="hasStoredSshKey && !clearStoredSshKey" [value]="'settings.sshKeyStoredTag' | translate" severity="success"></p-tag>
|
||||||
|
<p-tag *ngIf="clearStoredSshKey" [value]="'settings.sshKeyWillBeRemovedTag' | translate" severity="danger"></p-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-ssh-lock" *ngIf="hasStoredSshKey && !sshKeyVisible && !clearStoredSshKey">
|
||||||
|
<p>{{ 'settings.sshRevealHint' | translate }}</p>
|
||||||
|
<div class="form-field form-field--full">
|
||||||
|
<label>{{ 'settings.revealSshPassword' | translate }}</label>
|
||||||
|
<input pInputText type="password" [(ngModel)]="sshRevealPassword" [ngModelOptions]="{ standalone: true }" [placeholder]="'settings.revealSshPasswordPlaceholder' | translate" />
|
||||||
|
</div>
|
||||||
|
<div class="header-actions-row">
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-lock-open" [label]="'settings.revealSshKey' | translate" [loading]="unlockingSshKey" (click)="unlockSshKey()"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field form-field--full">
|
||||||
|
<label>{{ 'settings.globalSshPrivateKey' | translate }}</label>
|
||||||
|
<textarea
|
||||||
|
pTextarea
|
||||||
|
formControlName="global_ssh_key"
|
||||||
|
rows="14"
|
||||||
|
[placeholder]="(hasStoredSshKey && !sshKeyVisible && !clearStoredSshKey) ? ('settings.globalSshPrivateKeyHiddenPlaceholder' | translate) : ('settings.globalSshPrivateKeyPlaceholder' | translate)"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions-row settings-ssh-actions">
|
||||||
|
<button *ngIf="hasStoredSshKey && sshKeyVisible && !clearStoredSshKey" pButton type="button" severity="secondary" icon="pi pi-eye-slash" [label]="'settings.hideSshKey' | translate" (click)="hideSshKey()"></button>
|
||||||
|
<button *ngIf="hasStoredSshKey || form.controls.global_ssh_key.value" pButton type="button" severity="danger" icon="pi pi-trash" [label]="'settings.clearSshKey' | translate" (click)="clearSshKey()"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small class="settings-ssh-note" *ngIf="clearStoredSshKey">{{ 'settings.sshKeyClearNotice' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-ssh-panel">
|
||||||
|
<div class="settings-ssh-panel__header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'settings.switchosDefaultsTitle' | translate }}</strong>
|
||||||
|
<p>{{ 'settings.switchosDefaultsHint' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.defaultSwitchosUsername' | translate }}</label>
|
||||||
|
<input pInputText formControlName="default_switchos_username" placeholder="admin" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'settings.defaultSwitchosPassword' | translate }}</label>
|
||||||
|
<input pInputText formControlName="default_switchos_password" placeholder="••••••••" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-actions settings-actions settings-actions--sticky">
|
||||||
|
<button pButton type="submit" icon="pi pi-save" [label]="'settings.save' | translate" [disabled]="form.invalid || saving" [loading]="saving"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
499
frontend/src/app/features/settings/settings-page.component.ts
Normal file
499
frontend/src/app/features/settings/settings-page.component.ts
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, OnDestroy, OnInit, effect, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { SelectModule } from 'primeng/select';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
import { TextareaModule } from 'primeng/textarea';
|
||||||
|
import { TagModule } from 'primeng/tag';
|
||||||
|
import { Subject, finalize, forkJoin, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { AppFont, FontService } from '../../core/services/font.service';
|
||||||
|
import { APP_LANGUAGE_OPTIONS, AppLanguage, LanguageService } from '../../core/services/language.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
|
||||||
|
interface SchedulerJobStatus {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
cron?: string | null;
|
||||||
|
description: string;
|
||||||
|
description_params?: Record<string, string | number> | null;
|
||||||
|
valid: boolean;
|
||||||
|
next_runs: string[];
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchedulerStatusResponse {
|
||||||
|
timezone: string;
|
||||||
|
running: boolean;
|
||||||
|
jobs: SchedulerJobStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScheduleMode = 'disabled' | 'daily' | 'weekly' | 'custom';
|
||||||
|
type BooleanSettingControl = 'smtp_notifications_enabled' | 'notify_failures_only';
|
||||||
|
|
||||||
|
interface ScheduleEditor {
|
||||||
|
mode: ScheduleMode;
|
||||||
|
hour: string;
|
||||||
|
minute: string;
|
||||||
|
weekday: string;
|
||||||
|
cron: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsResponse {
|
||||||
|
backup_retention_days: number;
|
||||||
|
log_retention_days: number;
|
||||||
|
export_cron: string;
|
||||||
|
binary_cron: string;
|
||||||
|
retention_cron: string;
|
||||||
|
enable_auto_export: boolean;
|
||||||
|
connection_test_interval_minutes: number;
|
||||||
|
global_ssh_key: string | null;
|
||||||
|
has_global_ssh_key: boolean;
|
||||||
|
default_switchos_username: string | null;
|
||||||
|
default_switchos_password: string | null;
|
||||||
|
has_default_switchos_credentials: boolean;
|
||||||
|
pushover_token: string | null;
|
||||||
|
pushover_userkey: string | null;
|
||||||
|
notify_failures_only: boolean;
|
||||||
|
smtp_host: string | null;
|
||||||
|
smtp_port: number;
|
||||||
|
smtp_login: string | null;
|
||||||
|
smtp_password: string | null;
|
||||||
|
smtp_notifications_enabled: boolean;
|
||||||
|
recipient_email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ReactiveFormsModule, TranslateModule, ButtonModule, SelectModule, InputTextModule, TextareaModule, TagModule, PageHeaderComponent],
|
||||||
|
templateUrl: './settings-page.component.html'
|
||||||
|
})
|
||||||
|
export class SettingsPageComponent implements OnInit, OnDestroy {
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
private readonly language = inject(LanguageService);
|
||||||
|
private readonly font = inject(FontService);
|
||||||
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
saving = false;
|
||||||
|
testingEmail = false;
|
||||||
|
testingPushover = false;
|
||||||
|
unlockingSshKey = false;
|
||||||
|
schedulerStatus?: SchedulerStatusResponse;
|
||||||
|
hasStoredSshKey = false;
|
||||||
|
sshKeyVisible = false;
|
||||||
|
clearStoredSshKey = false;
|
||||||
|
sshRevealPassword = '';
|
||||||
|
|
||||||
|
readonly scheduleEditors: Record<'export' | 'binary' | 'retention', ScheduleEditor> = {
|
||||||
|
export: this.createEditor(),
|
||||||
|
binary: this.createEditor(),
|
||||||
|
retention: this.createEditor()
|
||||||
|
};
|
||||||
|
readonly form = this.fb.nonNullable.group({
|
||||||
|
backup_retention_days: [7, Validators.required],
|
||||||
|
log_retention_days: [7, Validators.required],
|
||||||
|
export_cron: '',
|
||||||
|
binary_cron: '',
|
||||||
|
retention_cron: '',
|
||||||
|
enable_auto_export: false,
|
||||||
|
connection_test_interval_minutes: [0, Validators.min(0)],
|
||||||
|
global_ssh_key: '',
|
||||||
|
default_switchos_username: '',
|
||||||
|
default_switchos_password: '',
|
||||||
|
pushover_token: '',
|
||||||
|
pushover_userkey: '',
|
||||||
|
notify_failures_only: true,
|
||||||
|
smtp_host: '',
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_login: '',
|
||||||
|
smtp_password: '',
|
||||||
|
smtp_notifications_enabled: false,
|
||||||
|
recipient_email: '',
|
||||||
|
preferred_language: 'pl' as AppLanguage,
|
||||||
|
preferred_font: 'default' as AppFont
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const user = this.auth.user();
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.form.patchValue(
|
||||||
|
{
|
||||||
|
preferred_language: user.preferred_language || 'pl',
|
||||||
|
preferred_font: user.preferred_font || 'default'
|
||||||
|
},
|
||||||
|
{ emitEvent: false }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get scheduleModeOptions() {
|
||||||
|
return [
|
||||||
|
{ label: this.ui.instant('settings.scheduleDisabled'), value: 'disabled' },
|
||||||
|
{ label: this.ui.instant('settings.scheduleDaily'), value: 'daily' },
|
||||||
|
{ label: this.ui.instant('settings.scheduleWeekly'), value: 'weekly' },
|
||||||
|
{ label: this.ui.instant('settings.scheduleCustom'), value: 'custom' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get weekdayOptions() {
|
||||||
|
return [
|
||||||
|
{ label: this.ui.instant('settings.weekdayMonday'), value: '1' },
|
||||||
|
{ label: this.ui.instant('settings.weekdayTuesday'), value: '2' },
|
||||||
|
{ label: this.ui.instant('settings.weekdayWednesday'), value: '3' },
|
||||||
|
{ label: this.ui.instant('settings.weekdayThursday'), value: '4' },
|
||||||
|
{ label: this.ui.instant('settings.weekdayFriday'), value: '5' },
|
||||||
|
{ label: this.ui.instant('settings.weekdaySaturday'), value: '6' },
|
||||||
|
{ label: this.ui.instant('settings.weekdaySunday'), value: '0' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly languageOptions = APP_LANGUAGE_OPTIONS.map((option) => ({
|
||||||
|
label: `${option.flag} ${option.label}`,
|
||||||
|
value: option.code
|
||||||
|
}));
|
||||||
|
|
||||||
|
get fontOptions() {
|
||||||
|
return [
|
||||||
|
{ label: this.ui.instant('settings.fontDefault'), value: 'default' },
|
||||||
|
{ label: 'Adwaita Mono', value: 'adwaita_mono' },
|
||||||
|
{ label: 'Hack', value: 'hack' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.form.controls.preferred_language.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((selectedLanguage) => {
|
||||||
|
this.previewLanguage(selectedLanguage as AppLanguage | null);
|
||||||
|
});
|
||||||
|
this.reloadSettings();
|
||||||
|
this.loadSchedulerStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.form.invalid || this.saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.syncEditorsToForm();
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
|
const preferredLanguage = this.form.controls.preferred_language.value as AppLanguage;
|
||||||
|
const preferredFont = this.form.controls.preferred_font.value as AppFont;
|
||||||
|
|
||||||
|
forkJoin({
|
||||||
|
settings: this.api.http.put(`${this.api.baseUrl}/settings`, this.buildPayload()),
|
||||||
|
preferences: this.auth.updatePreferences({ preferred_language: preferredLanguage, preferred_font: preferredFont })
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
finalize(() => {
|
||||||
|
this.saving = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.font.set(preferredFont);
|
||||||
|
this.ui.success('toast.settingsSaved');
|
||||||
|
this.reloadSettings();
|
||||||
|
this.loadSchedulerStatus();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.ui.error('toast.settingsSaveFailed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testEmail() {
|
||||||
|
if (this.testingEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.testingEmail = true;
|
||||||
|
this.api.http
|
||||||
|
.post(`${this.api.baseUrl}/settings/test-email`, {})
|
||||||
|
.pipe(
|
||||||
|
finalize(() => {
|
||||||
|
this.testingEmail = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => this.ui.success('toast.testEmailSent'),
|
||||||
|
error: () => {
|
||||||
|
this.ui.error('toast.testEmailFailed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testPushover() {
|
||||||
|
if (this.testingPushover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.testingPushover = true;
|
||||||
|
this.api.http
|
||||||
|
.post(`${this.api.baseUrl}/settings/test-pushover`, {})
|
||||||
|
.pipe(
|
||||||
|
finalize(() => {
|
||||||
|
this.testingPushover = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => this.ui.success('toast.testPushoverSent'),
|
||||||
|
error: () => {
|
||||||
|
this.ui.error('toast.testPushoverFailed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockSshKey() {
|
||||||
|
if (this.unlockingSshKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.sshRevealPassword.trim()) {
|
||||||
|
this.ui.error('settings.sshRevealPasswordRequired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.unlockingSshKey = true;
|
||||||
|
this.api.http
|
||||||
|
.post<{ global_ssh_key: string | null }>(`${this.api.baseUrl}/settings/reveal-ssh-key`, { password: this.sshRevealPassword })
|
||||||
|
.pipe(
|
||||||
|
finalize(() => {
|
||||||
|
this.unlockingSshKey = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.form.controls.global_ssh_key.setValue(response.global_ssh_key || '');
|
||||||
|
this.sshKeyVisible = true;
|
||||||
|
this.clearStoredSshKey = false;
|
||||||
|
this.ui.success('toast.sshKeyUnlocked');
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.ui.error('settings.sshRevealPasswordInvalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSshKey() {
|
||||||
|
if (!this.hasStoredSshKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sshKeyVisible = false;
|
||||||
|
this.form.controls.global_ssh_key.setValue('');
|
||||||
|
this.sshRevealPassword = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSshKey() {
|
||||||
|
this.clearStoredSshKey = true;
|
||||||
|
this.hasStoredSshKey = false;
|
||||||
|
this.sshKeyVisible = true;
|
||||||
|
this.form.controls.global_ssh_key.setValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulerJob(key: string): SchedulerJobStatus | undefined {
|
||||||
|
return this.schedulerStatus?.jobs.find((item) => item.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSummary(editor: ScheduleEditor): string {
|
||||||
|
if (editor.mode === 'disabled') {
|
||||||
|
return this.ui.instant('settings.scheduleDisabledHint');
|
||||||
|
}
|
||||||
|
if (editor.mode === 'daily') {
|
||||||
|
return this.ui.instant('settings.scheduleDailySummary', { time: `${editor.hour}:${editor.minute}` });
|
||||||
|
}
|
||||||
|
if (editor.mode === 'weekly') {
|
||||||
|
const weekday = this.weekdayOptions.find((item) => item.value === editor.weekday)?.label || '';
|
||||||
|
return this.ui.instant('settings.scheduleWeeklySummary', { weekday, time: `${editor.hour}:${editor.minute}` });
|
||||||
|
}
|
||||||
|
return editor.cron || this.ui.instant('settings.scheduleCustomEmpty');
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionTestSummary(): string {
|
||||||
|
const minutes = Number(this.form.controls.connection_test_interval_minutes.value || 0);
|
||||||
|
return minutes > 0 ? this.ui.instant('settings.connectionTestsEverySummary', { minutes }) : this.ui.instant('settings.connectionTestsDisabledHint');
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleEnabled(editor: ScheduleEditor): boolean {
|
||||||
|
return editor.mode !== 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSeverity(editor: ScheduleEditor): 'success' | 'secondary' {
|
||||||
|
return this.scheduleEnabled(editor) ? 'success' : 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionTestSeverity(): 'success' | 'secondary' {
|
||||||
|
return Number(this.form.controls.connection_test_interval_minutes.value || 0) > 0 ? 'success' : 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
setBooleanSetting(controlName: BooleanSettingControl, value: boolean) {
|
||||||
|
this.form.controls[controlName].setValue(value);
|
||||||
|
this.form.controls[controlName].markAsDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeTime(editor: ScheduleEditor) {
|
||||||
|
editor.hour = this.padTime(editor.hour, 23);
|
||||||
|
editor.minute = this.padTime(editor.minute, 59);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewFont() {
|
||||||
|
this.font.set(this.form.controls.preferred_font.value as AppFont);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewLanguage(selectedLanguage?: AppLanguage | null) {
|
||||||
|
const nextLanguage = (selectedLanguage || this.form.controls.preferred_language.value || 'pl') as AppLanguage;
|
||||||
|
this.form.controls.preferred_language.setValue(nextLanguage, { emitEvent: false });
|
||||||
|
if (this.auth.user()) {
|
||||||
|
this.language.setForAuthenticatedUser(nextLanguage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.language.set(nextLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEditor(): ScheduleEditor {
|
||||||
|
return { mode: 'disabled', hour: '02', minute: '00', weekday: '1', cron: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private reloadSettings() {
|
||||||
|
this.api.http.get<SettingsResponse>(`${this.api.baseUrl}/settings`).subscribe((response) => {
|
||||||
|
this.hasStoredSshKey = response.has_global_ssh_key;
|
||||||
|
this.sshKeyVisible = !response.has_global_ssh_key;
|
||||||
|
this.clearStoredSshKey = false;
|
||||||
|
this.sshRevealPassword = '';
|
||||||
|
const user = this.auth.user();
|
||||||
|
this.form.patchValue({
|
||||||
|
backup_retention_days: response.backup_retention_days,
|
||||||
|
log_retention_days: response.log_retention_days,
|
||||||
|
export_cron: response.export_cron || '',
|
||||||
|
binary_cron: response.binary_cron || '',
|
||||||
|
retention_cron: response.retention_cron || '',
|
||||||
|
enable_auto_export: response.enable_auto_export,
|
||||||
|
connection_test_interval_minutes: Number(response.connection_test_interval_minutes || 0),
|
||||||
|
global_ssh_key: '',
|
||||||
|
default_switchos_username: response.default_switchos_username || '',
|
||||||
|
default_switchos_password: response.default_switchos_password || '',
|
||||||
|
pushover_token: response.pushover_token || '',
|
||||||
|
pushover_userkey: response.pushover_userkey || '',
|
||||||
|
notify_failures_only: response.notify_failures_only,
|
||||||
|
smtp_host: response.smtp_host || '',
|
||||||
|
smtp_port: Number(response.smtp_port || 587),
|
||||||
|
smtp_login: response.smtp_login || '',
|
||||||
|
smtp_password: response.smtp_password || '',
|
||||||
|
smtp_notifications_enabled: response.smtp_notifications_enabled,
|
||||||
|
recipient_email: response.recipient_email || '',
|
||||||
|
preferred_language: (user?.preferred_language || this.language.current() || 'pl') as AppLanguage,
|
||||||
|
preferred_font: (user?.preferred_font || 'default') as AppFont
|
||||||
|
}, { emitEvent: false });
|
||||||
|
this.hydrateEditors();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPayload() {
|
||||||
|
const raw = this.form.getRawValue();
|
||||||
|
const normalizedKey = raw.global_ssh_key.trim();
|
||||||
|
return {
|
||||||
|
backup_retention_days: Number(raw.backup_retention_days || 0),
|
||||||
|
log_retention_days: Number(raw.log_retention_days || 0),
|
||||||
|
export_cron: (raw.export_cron || '').trim(),
|
||||||
|
binary_cron: (raw.binary_cron || '').trim(),
|
||||||
|
retention_cron: (raw.retention_cron || '').trim(),
|
||||||
|
enable_auto_export: Boolean(raw.enable_auto_export),
|
||||||
|
connection_test_interval_minutes: Number(raw.connection_test_interval_minutes || 0),
|
||||||
|
global_ssh_key: normalizedKey || null,
|
||||||
|
default_switchos_username: this.normalizeOptionalText(raw.default_switchos_username),
|
||||||
|
default_switchos_password: this.normalizeOptionalText(raw.default_switchos_password),
|
||||||
|
pushover_token: this.normalizeOptionalText(raw.pushover_token),
|
||||||
|
pushover_userkey: this.normalizeOptionalText(raw.pushover_userkey),
|
||||||
|
notify_failures_only: Boolean(raw.notify_failures_only),
|
||||||
|
smtp_host: this.normalizeOptionalText(raw.smtp_host),
|
||||||
|
smtp_port: Number(raw.smtp_port || 587),
|
||||||
|
smtp_login: this.normalizeOptionalText(raw.smtp_login),
|
||||||
|
smtp_password: this.normalizeOptionalText(raw.smtp_password),
|
||||||
|
smtp_notifications_enabled: Boolean(raw.smtp_notifications_enabled),
|
||||||
|
recipient_email: this.normalizeOptionalText(raw.recipient_email),
|
||||||
|
clear_global_ssh_key: this.clearStoredSshKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private hydrateEditors() {
|
||||||
|
const exportEditor = this.editorFromCron(this.form.controls.export_cron.value);
|
||||||
|
if (!this.form.controls.enable_auto_export.value) {
|
||||||
|
exportEditor.mode = 'disabled';
|
||||||
|
}
|
||||||
|
this.scheduleEditors.export = exportEditor;
|
||||||
|
this.scheduleEditors.binary = this.editorFromCron(this.form.controls.binary_cron.value);
|
||||||
|
this.scheduleEditors.retention = this.editorFromCron(this.form.controls.retention_cron.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncEditorsToForm() {
|
||||||
|
this.normalizeTime(this.scheduleEditors.export);
|
||||||
|
this.normalizeTime(this.scheduleEditors.binary);
|
||||||
|
this.normalizeTime(this.scheduleEditors.retention);
|
||||||
|
this.form.patchValue({
|
||||||
|
export_cron: this.editorToCron(this.scheduleEditors.export),
|
||||||
|
binary_cron: this.editorToCron(this.scheduleEditors.binary),
|
||||||
|
retention_cron: this.editorToCron(this.scheduleEditors.retention),
|
||||||
|
enable_auto_export: this.scheduleEditors.export.mode !== 'disabled'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private editorFromCron(cron: string): ScheduleEditor {
|
||||||
|
const normalized = (cron || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return this.createEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalized.split(/\s+/);
|
||||||
|
if (parts.length === 5) {
|
||||||
|
const [minute, hour, day, month, dayOfWeek] = parts;
|
||||||
|
if (day === '*' && month === '*' && dayOfWeek === '*') {
|
||||||
|
return { mode: 'daily', hour: this.padTime(hour, 23), minute: this.padTime(minute, 59), weekday: '1', cron: normalized };
|
||||||
|
}
|
||||||
|
if (day === '*' && month === '*' && /^[0-7]$/.test(dayOfWeek)) {
|
||||||
|
return { mode: 'weekly', hour: this.padTime(hour, 23), minute: this.padTime(minute, 59), weekday: dayOfWeek, cron: normalized };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: 'custom', hour: '02', minute: '00', weekday: '1', cron: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
private editorToCron(editor: ScheduleEditor): string {
|
||||||
|
if (editor.mode === 'disabled') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (editor.mode === 'daily') {
|
||||||
|
return `${editor.minute} ${editor.hour} * * *`;
|
||||||
|
}
|
||||||
|
if (editor.mode === 'weekly') {
|
||||||
|
return `${editor.minute} ${editor.hour} * * ${editor.weekday}`;
|
||||||
|
}
|
||||||
|
return editor.cron.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private padTime(value: string, max: number): string {
|
||||||
|
const numeric = Math.min(max, Math.max(0, Number(value || 0)));
|
||||||
|
return String(Math.floor(numeric)).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeOptionalText(value: string | null | undefined): string | null {
|
||||||
|
const normalized = (value || '').trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSchedulerStatus() {
|
||||||
|
this.api.http.get<SchedulerStatusResponse>(`${this.api.baseUrl}/settings/scheduler-status`).subscribe((status) => {
|
||||||
|
this.schedulerStatus = status;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<app-page-header [eyebrow]="'switchosBeta.eyebrow' | translate" [title]="'switchosBeta.title' | translate" [subtitle]="'switchosBeta.subtitle' | translate">
|
||||||
|
<div header-actions class="header-actions-row">
|
||||||
|
<p-tag severity="warn" [value]="'switchosBeta.betaTag' | translate"></p-tag>
|
||||||
|
</div>
|
||||||
|
</app-page-header>
|
||||||
|
|
||||||
|
<app-section-card [title]="'switchosBeta.warningTitle' | translate" [subtitle]="'switchosBeta.warningSubtitle' | translate">
|
||||||
|
<div class="beta-banner">
|
||||||
|
<div>
|
||||||
|
<strong>{{ 'switchosBeta.warningHeadline' | translate }}</strong>
|
||||||
|
<p>{{ 'switchosBeta.warningBody' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<p-tag severity="warn" [value]="'switchosBeta.betaTag' | translate"></p-tag>
|
||||||
|
</div>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<div class="metric-grid-2 swos-beta-grid">
|
||||||
|
<app-section-card [title]="'switchosBeta.formTitle' | translate" [subtitle]="'switchosBeta.formSubtitle' | translate">
|
||||||
|
<form [formGroup]="form" class="form-grid-2">
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'switchosBeta.label' | translate }}</label>
|
||||||
|
<input pInputText formControlName="label" [placeholder]="'switchosBeta.labelPlaceholder' | translate" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'switchosBeta.host' | translate }}</label>
|
||||||
|
<input pInputText formControlName="host" [placeholder]="'switchosBeta.hostPlaceholder' | translate" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'switchosBeta.port' | translate }}</label>
|
||||||
|
<input pInputText type="number" formControlName="port" placeholder="80" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field">
|
||||||
|
<label>{{ 'switchosBeta.username' | translate }}</label>
|
||||||
|
<input pInputText formControlName="username" placeholder="admin" />
|
||||||
|
</span>
|
||||||
|
<span class="form-field form-field--full">
|
||||||
|
<label>{{ 'switchosBeta.password' | translate }}</label>
|
||||||
|
<input pInputText type="password" formControlName="password" [placeholder]="'switchosBeta.passwordPlaceholder' | translate" />
|
||||||
|
</span>
|
||||||
|
<div class="dialog-actions swos-beta-actions">
|
||||||
|
<button pButton type="button" severity="secondary" icon="pi pi-search" [label]="'switchosBeta.probeButton' | translate" [loading]="probing" (click)="probe()"></button>
|
||||||
|
<button pButton type="button" icon="pi pi-download" [label]="'switchosBeta.downloadButton' | translate" [loading]="downloading" (click)="download()"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
|
<app-section-card [title]="'switchosBeta.resultTitle' | translate" [subtitle]="'switchosBeta.resultSubtitle' | translate">
|
||||||
|
<div class="empty-state" *ngIf="!probeResult && !lastError">{{ 'switchosBeta.resultEmpty' | translate }}</div>
|
||||||
|
|
||||||
|
<div class="swos-beta-result" *ngIf="probeResult">
|
||||||
|
<div class="swos-beta-result__item">
|
||||||
|
<span>{{ 'switchosBeta.baseUrl' | translate }}</span>
|
||||||
|
<strong>{{ probeResult.base_url }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="swos-beta-result__item">
|
||||||
|
<span>{{ 'switchosBeta.httpStatus' | translate }}</span>
|
||||||
|
<strong>{{ probeResult.status_code }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="swos-beta-result__item">
|
||||||
|
<span>{{ 'switchosBeta.authMode' | translate }}</span>
|
||||||
|
<strong>{{ probeResult.auth_mode }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="swos-beta-result__item">
|
||||||
|
<span>{{ 'switchosBeta.pageTitle' | translate }}</span>
|
||||||
|
<strong>{{ probeResult.page_title || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="swos-beta-result__item">
|
||||||
|
<span>{{ 'switchosBeta.serverHeader' | translate }}</span>
|
||||||
|
<strong>{{ probeResult.server || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="swos-beta-result__item">
|
||||||
|
<span>{{ 'switchosBeta.backupEndpoint' | translate }}</span>
|
||||||
|
<strong>{{ (probeResult.backup_endpoint_ok ? 'switchosBeta.available' : 'switchosBeta.unavailable') | translate }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="swos-beta-note" *ngIf="probeResult.note">{{ probeResult.note }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="beta-error" *ngIf="lastError">{{ lastError }}</div>
|
||||||
|
</app-section-card>
|
||||||
|
</div>
|
||||||
131
frontend/src/app/features/swos-beta/swos-beta-page.component.ts
Normal file
131
frontend/src/app/features/swos-beta/swos-beta-page.component.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { finalize } from 'rxjs';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
import { TagModule } from 'primeng/tag';
|
||||||
|
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import { PageHeaderComponent } from '../../shared/ui/page-header.component';
|
||||||
|
import { SectionCardComponent } from '../../shared/ui/section-card.component';
|
||||||
|
|
||||||
|
interface SwosBetaProbeResult {
|
||||||
|
success: boolean;
|
||||||
|
base_url: string;
|
||||||
|
status_code: number;
|
||||||
|
auth_mode: string;
|
||||||
|
page_title?: string | null;
|
||||||
|
content_type?: string | null;
|
||||||
|
server?: string | null;
|
||||||
|
save_backup_visible: boolean;
|
||||||
|
backup_endpoint_ok: boolean;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, TranslateModule, ButtonModule, InputTextModule, TagModule, PageHeaderComponent, SectionCardComponent],
|
||||||
|
templateUrl: './swos-beta-page.component.html'
|
||||||
|
})
|
||||||
|
export class SwosBetaPageComponent {
|
||||||
|
private readonly api = inject(ApiService);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly ui = inject(UiService);
|
||||||
|
|
||||||
|
probing = false;
|
||||||
|
downloading = false;
|
||||||
|
lastError = '';
|
||||||
|
probeResult?: SwosBetaProbeResult;
|
||||||
|
|
||||||
|
readonly form = this.fb.nonNullable.group({
|
||||||
|
label: '',
|
||||||
|
host: ['', Validators.required],
|
||||||
|
port: [80, [Validators.required, Validators.min(1), Validators.max(65535)]],
|
||||||
|
username: ['admin', Validators.required],
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
get formValue() {
|
||||||
|
return this.form.getRawValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
probe() {
|
||||||
|
if (this.form.invalid || this.probing) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastError = '';
|
||||||
|
this.probing = true;
|
||||||
|
this.api.http
|
||||||
|
.post<SwosBetaProbeResult>(`${this.api.baseUrl}/swos-beta/probe`, this.formValue)
|
||||||
|
.pipe(finalize(() => (this.probing = false)))
|
||||||
|
.subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.probeResult = result;
|
||||||
|
this.ui.success('toast.swosBetaProbeOk');
|
||||||
|
},
|
||||||
|
error: (error: HttpErrorResponse) => {
|
||||||
|
this.probeResult = undefined;
|
||||||
|
this.lastError = this.extractError(error);
|
||||||
|
this.ui.error('toast.swosBetaProbeFailed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
download() {
|
||||||
|
if (this.form.invalid || this.downloading) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastError = '';
|
||||||
|
this.downloading = true;
|
||||||
|
this.api.http
|
||||||
|
.post(`${this.api.baseUrl}/swos-beta/download`, this.formValue, {
|
||||||
|
observe: 'response',
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
.pipe(finalize(() => (this.downloading = false)))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.saveBlob(response);
|
||||||
|
this.ui.success('toast.swosBetaDownloadOk');
|
||||||
|
},
|
||||||
|
error: (error: HttpErrorResponse) => {
|
||||||
|
this.lastError = this.extractError(error);
|
||||||
|
this.ui.error('toast.swosBetaDownloadFailed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveBlob(response: HttpResponse<Blob>) {
|
||||||
|
const blob = response.body || new Blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = this.extractFilename(response.headers.get('content-disposition'));
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFilename(disposition: string | null): string {
|
||||||
|
const match = disposition?.match(/filename="?([^\"]+)"?/i);
|
||||||
|
return match?.[1] || 'switchos-backup.swb';
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractError(error: HttpErrorResponse): string {
|
||||||
|
const detail = error.error?.detail;
|
||||||
|
if (typeof detail === 'string' && detail.trim()) {
|
||||||
|
return detail.trim();
|
||||||
|
}
|
||||||
|
if (typeof error.error === 'string' && error.error.trim()) {
|
||||||
|
return error.error.trim();
|
||||||
|
}
|
||||||
|
return this.ui.instant('switchosBeta.genericError');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/src/app/shared/auth/auth-toolbar.component.html
Normal file
22
frontend/src/app/shared/auth/auth-toolbar.component.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<div class="auth-toolbar">
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
class="auth-toolbar__btn"
|
||||||
|
[icon]="theme.mode() === 'dark' ? 'pi pi-sun' : 'pi pi-moon'"
|
||||||
|
(click)="theme.toggle()"
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<label class="auth-toolbar__select-wrap">
|
||||||
|
<select
|
||||||
|
class="auth-toolbar__select"
|
||||||
|
[ngModel]="language.current()"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(ngModelChange)="changeLanguage($event)"
|
||||||
|
>
|
||||||
|
<option *ngFor="let option of languageOptions; trackBy: trackByLanguageCode" [ngValue]="option.code">
|
||||||
|
{{ option.flag }} {{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
28
frontend/src/app/shared/auth/auth-toolbar.component.ts
Normal file
28
frontend/src/app/shared/auth/auth-toolbar.component.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
|
||||||
|
import { AppLanguage, AppLanguageOption, LanguageService } from '../../core/services/language.service';
|
||||||
|
import { ThemeService } from '../../core/services/theme.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-auth-toolbar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ButtonModule],
|
||||||
|
templateUrl: './auth-toolbar.component.html'
|
||||||
|
})
|
||||||
|
export class AuthToolbarComponent {
|
||||||
|
readonly theme = inject(ThemeService);
|
||||||
|
readonly language = inject(LanguageService);
|
||||||
|
|
||||||
|
readonly languageOptions = this.language.options;
|
||||||
|
|
||||||
|
trackByLanguageCode(_: number, option: AppLanguageOption) {
|
||||||
|
return option.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLanguage(lang: AppLanguage | string) {
|
||||||
|
this.language.set(lang as AppLanguage);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
frontend/src/app/shared/layout/app-sidebar.component.html
Normal file
27
frontend/src/app/shared/layout/app-sidebar.component.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="sidebar-brand__logo">
|
||||||
|
<img src="https://mikrotik.com/logo/library/logo/SVG/MT_Symbol_Black.svg" alt="MikroTik" />
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-brand__text" *ngIf="!collapsed">
|
||||||
|
<h2>{{ 'sidebar.title' | translate }}</h2>
|
||||||
|
<p>{{ 'sidebar.subtitle' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section" *ngIf="!collapsed">
|
||||||
|
<div class="sidebar-section__label">{{ 'app.menu' | translate }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a
|
||||||
|
*ngFor="let item of items"
|
||||||
|
[routerLink]="item.link"
|
||||||
|
routerLinkActive="is-active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: item.exact ?? true }"
|
||||||
|
class="sidebar-nav__item"
|
||||||
|
(click)="navigate.emit()"
|
||||||
|
>
|
||||||
|
<i [class]="item.icon"></i>
|
||||||
|
<span *ngIf="!collapsed">{{ item.label | translate }}</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
16
frontend/src/app/shared/layout/app-sidebar.component.ts
Normal file
16
frontend/src/app/shared/layout/app-sidebar.component.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sidebar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule],
|
||||||
|
templateUrl: './app-sidebar.component.html'
|
||||||
|
})
|
||||||
|
export class AppSidebarComponent {
|
||||||
|
@Input() collapsed = false;
|
||||||
|
@Input() items: Array<{ label: string; link: string; icon: string; exact?: boolean }> = [];
|
||||||
|
@Output() navigate = new EventEmitter<void>();
|
||||||
|
}
|
||||||
42
frontend/src/app/shared/layout/app-topbar.component.html
Normal file
42
frontend/src/app/shared/layout/app-topbar.component.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar__left">
|
||||||
|
<button pButton type="button" icon="pi pi-bars" styleClass="topbar__icon-btn p-button-text" (click)="menuClick.emit()"></button>
|
||||||
|
<div>
|
||||||
|
<div class="topbar__caption">{{ 'topbar.caption' | translate }}</div>
|
||||||
|
<div class="topbar__headline">{{ pageTitle | translate }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar__right">
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
styleClass="topbar__icon-btn p-button-text"
|
||||||
|
[icon]="themeMode === 'dark' ? 'pi pi-sun' : 'pi pi-moon'"
|
||||||
|
(click)="themeClick.emit()"
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<label class="topbar__lang-picker" [attr.aria-label]="'topbar.languageSelector' | translate">
|
||||||
|
<select
|
||||||
|
class="topbar__lang-select"
|
||||||
|
[ngModel]="lang"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(ngModelChange)="onLanguageSelect($event)"
|
||||||
|
>
|
||||||
|
<option *ngFor="let option of languages; trackBy: trackByLanguageCode" [ngValue]="option.code">
|
||||||
|
{{ option.flag }} {{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="topbar__user">
|
||||||
|
<p-avatar [label]="userInitials" shape="circle" styleClass="topbar__avatar"></p-avatar>
|
||||||
|
<div class="topbar__user-meta">
|
||||||
|
<strong>{{ username }}</strong>
|
||||||
|
<small>{{ 'topbar.role' | translate }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button pButton type="button" styleClass="p-button-outlined topbar__logout-btn" icon="pi pi-sign-out" [label]="'nav.logout' | translate" (click)="logoutClick.emit()"></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
42
frontend/src/app/shared/layout/app-topbar.component.ts
Normal file
42
frontend/src/app/shared/layout/app-topbar.component.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { AvatarModule } from 'primeng/avatar';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
|
||||||
|
export interface TopbarLanguageOption {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
flag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-topbar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, TranslateModule, ButtonModule, AvatarModule],
|
||||||
|
templateUrl: './app-topbar.component.html'
|
||||||
|
})
|
||||||
|
export class AppTopbarComponent {
|
||||||
|
@Input() pageTitle = 'dashboard.title';
|
||||||
|
@Input() username = 'admin';
|
||||||
|
@Input() lang = 'pl';
|
||||||
|
@Input() themeMode: 'light' | 'dark' = 'light';
|
||||||
|
@Input() languages: TopbarLanguageOption[] = [];
|
||||||
|
@Output() menuClick = new EventEmitter<void>();
|
||||||
|
@Output() themeClick = new EventEmitter<void>();
|
||||||
|
@Output() languageChange = new EventEmitter<string>();
|
||||||
|
@Output() logoutClick = new EventEmitter<void>();
|
||||||
|
|
||||||
|
get userInitials(): string {
|
||||||
|
return this.username.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByLanguageCode(_: number, option: TopbarLanguageOption) {
|
||||||
|
return option.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
onLanguageSelect(value: string) {
|
||||||
|
this.languageChange.emit(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user