first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-14 11:39:46 +02:00
commit 3da6c2832c
125 changed files with 30111 additions and 0 deletions

11
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

View 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
View 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

View 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"}

View 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")

View 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,
)

View 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()}

View 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}

View 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)

View 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'}

View 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}"'},
)

View 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()

View 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'

View 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
View 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
View 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
View 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)

View 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
View 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)

View 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")

View 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)

View 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')

View 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)

View 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]

View 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]

View 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

View 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]

View 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

View 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()

View 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)

View 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()

View 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()

View 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()

View 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()

View 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()

View 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
View 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

View 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())

View 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"

View 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"}

View 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')

View 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

View 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
View 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
View 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
View 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
View 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"
}
}
}
}
}

View 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

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View 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
View File

@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://127.0.0.1:8000",
"secure": false,
"changeOrigin": true,
"logLevel": "info"
}
}

View 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>

View 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';
}
}

View 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: '' }
];

View 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;
};

View 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;
};

View 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}` } }));
};

View 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;
});
}
}

View 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) {}
}

View 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');
}
}

View 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"
};
}
}
}

View 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;
}
}

View 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);
}
}

View 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'));
});
}
}

View 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);
}
}

View 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;

View File

@@ -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>

View File

@@ -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;
}
});
}
}

View 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>

View 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;
}
});
}
}

View 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>

View 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;
}
});
}
}

View File

@@ -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>

View 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;
}
}

View File

@@ -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>

View File

@@ -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];
}
}

View 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>

View 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);
}
}

View 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>

View 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;
}
});
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View 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>

View 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 });
}
}

View 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>

View 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;
});
}
}

View File

@@ -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>

View 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');
}
}

View 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>

View 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);
}
}

View 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>

View 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>();
}

View 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>

View 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