first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-12 21:26:12 +02:00
commit ff7dbcb4e4
123 changed files with 27749 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=5580
API_PREFIX=/api
DATA_DIR=/app/storage
DATABASE_URL=sqlite:////app/storage/routeros_backup_next.db

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# 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/*

95
README.md Normal file
View File

@@ -0,0 +1,95 @@
# RouterOS Backup Manager Next
Refactor starej aplikacji Flask/Bootstrap do architektury:
- **backend:** FastAPI + SQLAlchemy + APScheduler
- **frontend:** Angular + PrimeNG + ngx-translate
- **dev:** lokalnie bez Dockera
- **prod:** Docker Compose + Nginx + FastAPI
## Co poprawiono względem poprzedniej iteracji
- usunięte bezpośrednie wywołanie `uvicorn` ze skryptów startowych
- zależności backendu zaktualizowane pod nowszy FastAPI/Pydantic i Python 3.14
- dev frontend domyślnie startuje na `127.0.0.1`, więc znika ostrzeżenie o otwartym `0.0.0.0`
- dodane środowisko produkcyjne: `Dockerfile`, `docker-compose.yml`, nginx proxy
- dodane `.env.example`, `.gitignore`, `.dockerignore`, `start_prod.sh`
- dodane brakujące ekrany: rejestracja, zmiana hasła
- frontend przebudowany wizualnie w kierunku **Avalon-inspired PrimeNG admin shell**
- wydzielone wspólne komponenty UI: `app-topbar`, `app-sidebar`, `app-page-header`, `app-stat-card`, `app-section-card`
- dodane brakujące operacje UI: edycja/usuwanie routera, export-all, binary-all, filtry i sortowanie plików
- dodany HTML diff side-by-side
- dodany migrator starej bazy SQLite
- przywrócona automatyczna retencja logów
## Struktura
- `backend/` FastAPI API
- `frontend/` Angular UI
- `backend/scripts/migrate_legacy_sqlite.py` migracja danych ze starej SQLite
- `start_dev.sh` start lokalny bez Dockera
- `start_prod.sh` start produkcyjny przez Docker Compose
- `.env.example` konfiguracja Docker/produkcyjna
- `backend/.env.dev.example` konfiguracja lokalna dla deweloperki bez Dockera
- `FEATURE_AUDIT.md` porównanie starej i nowej wersji funkcjonalnie
## Dev bez Dockera
Wymagania:
- Python 3.13 lub 3.14
- Node.js 22+
- npm
Start:
```bash
cp backend/.env.dev.example backend/.env
./start_dev.sh
```
Adresy:
- backend: `http://127.0.0.1:8000`
- docs: `http://127.0.0.1:8000/docs`
- frontend: `http://127.0.0.1:4200`
Dla wystawienia UI w LAN użyj:
```bash
cd frontend
npm run start:lan
```
Domyślne konto po pierwszym starcie:
- login: `admin`
- hasło: `admin`
## Produkcja w Dockerze
```bash
cp .env.example .env
# uzupełnij SECRET_KEY i DEFAULT_ADMIN_PASSWORD
./start_prod.sh
```
Domyślnie frontend będzie dostępny na:
- `http://127.0.0.1:8080`
## Konfiguracja środowisk
### Docker / produkcja (`.env` w katalogu głównym)
Najważniejsze zmienne:
- `SECRET_KEY`
- `DATABASE_URL`
- `DATA_DIR`
- `ALLOW_REGISTRATION`
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_PASSWORD`
- `CORS_ORIGINS`
- `FRONTEND_PORT`
## Migracja starej bazy Flask/SQLite
Jeżeli masz starą bazę `backup_routeros.db`, możesz zaimportować dane:
```bash
cd backend
PYTHONPATH=. python scripts/migrate_legacy_sqlite.py /sciezka/do/backup_routeros.db
```
### Dev bez Dockera (`backend/.env`)
Lokalny backend korzysta z `backend/.env`. Najprościej zacząć od:
```bash
cp backend/.env.dev.example backend/.env
```

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.13-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,14 @@
from fastapi import APIRouter
from app.api.routes import auth, backups, dashboard, health, logs, routers, settings, swos_beta
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"])
api_router.include_router(swos_beta.router, prefix='/swos-beta', tags=['swos-beta'])

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,125 @@
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),
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,
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,71 @@
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()
@router.get("", response_model=list[RouterResponse])
def list_routers(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return db.query(Router).filter(Router.owner_id == current_user.id).order_by(Router.created_at.desc()).all()
@router.post("", response_model=RouterResponse)
def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = Router(**payload.model_dump(), owner_id=current_user.id)
db.add(router)
db.commit()
db.refresh(router)
return router
@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="Router not found")
return router
@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="Router not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(router, key, value)
db.add(router)
db.commit()
db.refresh(router)
return router
@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="Router 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": "Router 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="Router not found")
settings = settings_service.get_or_create(db)
return router_service.test_connection(db, router, settings.global_ssh_key)

View File

@@ -0,0 +1,75 @@
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())
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 = 'RouterOS Backup Manager Next'
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"]

76
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,76 @@
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')
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', '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)')
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,28 @@
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)
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)
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,26 @@
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)
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,49 @@
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
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,60 @@
import re
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
ALLOWED_NAME_REGEX = re.compile(r"^[A-Za-z0-9_-]+$")
class RouterBase(BaseModel):
name: str = Field(min_length=1, max_length=120)
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=22, ge=1, le=65535)
ssh_user: str = Field(default="admin", min_length=1, 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
class RouterCreate(RouterBase):
pass
class RouterUpdate(BaseModel):
name: str | 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
class RouterResponse(RouterBase):
id: int
owner_id: int
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
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

View File

@@ -0,0 +1,85 @@
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
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', mode='before')
@classmethod
def normalize_key(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
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,321 @@
import difflib
from datetime import datetime, 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 _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='Router 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,
'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,
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)
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)
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 router {router.name}')
notification_service.notify(settings, f'Export {router.name} OK', True)
return backup
except Exception as exc:
notification_service.notify(settings, f'Export {router.name} FAIL: {exc}', False)
log_service.add(db, f'Export FAILED for router {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}'
name = f'{base_name}.backup'
file_path = ensure_data_dir() / name
try:
router_service.binary_backup(router, base_name, str(file_path), settings.global_ssh_key)
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 router {router.name}')
notification_service.notify(settings, f'Backup {router.name} OK', True)
return backup
except Exception as exc:
notification_service.notify(settings, f'Backup {router.name} FAIL: {exc}', False)
log_service.add(db, f'Binary backup FAILED for router {router.name}: {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)
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')
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 router {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)
noun = 'Export' if backup.backup_type == 'export' else 'Backup'
subject = f'RouterOS {noun}: {backup.file_name}'
body = f'Sending {backup.file_name} from router {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:
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, "RouterOS Backup 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, "RouterOS Backup test", "This is a test email from RouterOS Backup Manager Next")
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 RouterOS Backup Manager Next",
)
notification_service = NotificationService()

View File

@@ -0,0 +1,140 @@
from datetime import datetime
import io
from pathlib import Path
import paramiko
from sqlalchemy.orm import Session
from app.models.router import Router
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:
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) -> str:
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):
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_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,
}
except Exception as exc:
return {
"success": False,
"tested_at": tested_at,
"model": "Unknown",
"uptime": "Unknown",
"hostname": router.name,
"version": None,
"error": str(exc),
}
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")
db.add(router)
db.commit()
db.refresh(router)
return result
def test_connection(self, db: Session, router: Router, global_ssh_key: str | None = None):
result = self.probe_connection(router, global_ssh_key)
return self._store_connection_result(db, router, 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,124 @@
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
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='Moduł działa osobno i nie zapisuje kopii do głównego repozytorium.'
)
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 _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 SwOS ({", ".join(attempts)}).')
raise ValueError('Nie udało się połączyć ze SwOS.')
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}-swos-{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,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'

View File

@@ -0,0 +1,74 @@
from fastapi.testclient import TestClient
from app.main import app
from app.schemas.swos_beta import SwosBetaProbeResponse
def _login(client: TestClient) -> str:
response = client.post('/api/auth/login', data={'username': 'admin', 'password': 'admin'})
return response.json()['access_token']
def test_swos_probe_endpoint(monkeypatch, tmp_path):
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_probe.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')
from app.api.routes import swos_beta
monkeypatch.setattr(
swos_beta.swos_beta_service,
'probe',
lambda payload: SwosBetaProbeResponse(
success=True,
base_url='http://192.168.88.1',
status_code=200,
auth_mode='digest',
page_title='SwOS',
content_type='text/html',
server='MikroTik',
save_backup_visible=True,
backup_endpoint_ok=True,
note='beta',
),
)
with TestClient(app) as client:
token = _login(client)
response = client.post(
'/api/swos-beta/probe',
json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''},
headers={'Authorization': f'Bearer {token}'},
)
assert response.status_code == 200
assert response.json()['backup_endpoint_ok'] is True
def test_swos_download_endpoint(monkeypatch, tmp_path):
monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_download.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')
from app.api.routes import swos_beta
class FakeBackup:
filename = 'switch.swb'
content = b'binary-data'
content_type = 'application/octet-stream'
monkeypatch.setattr(swos_beta.swos_beta_service, 'download_backup', lambda payload: FakeBackup())
with TestClient(app) as client:
token = _login(client)
response = client.post(
'/api/swos-beta/download',
json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''},
headers={'Authorization': f'Bearer {token}'},
)
assert response.status_code == 200
assert response.content == b'binary-data'
assert 'attachment; filename="switch.swb"' == response.headers['content-disposition']

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
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
reverse-proxy:
build:
context: .
dockerfile: frontend/Dockerfile
container_name: routeros-backup-reverse-proxy
depends_on:
backend:
condition: service_healthy
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
FROM nginx:1.29-alpine
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;"]

37
frontend/angular.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"projects": {
"routeros-backup-manager-next-ui": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/routeros-backup-manager-next-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",
"node_modules/primeng/resources/themes/lara-light-blue/theme.css",
"node_modules/primeng/resources/primeng.min.css",
"src/styles.css"
]
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "routeros-backup-manager-next-ui:build"
}
}
}
}
}
}

View File

@@ -0,0 +1,78 @@
upstream backend_upstream {
server backend:8000;
keepalive 16;
}
server {
listen 80;
server_name _;
server_tokens off;
etag off;
root /usr/share/nginx/html;
index index.html;
#gzip on;
#gzip_comp_level 5;
#gzip_min_length 1024;
#gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
location ^~ /assets/ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable" always;
try_files $uri =404;
}
location = /favicon.ico {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
try_files $uri =404;
}
location = /index.html {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri =404;
}
location ^~ /api/ {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
proxy_pass http://backend_upstream/api/;
}
location = /docs {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
proxy_pass http://backend_upstream/docs;
}
location = /openapi.json {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
proxy_pass http://backend_upstream/openapi.json;
}
location = /redoc {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
proxy_pass http://backend_upstream/redoc;
}
location / {
try_files $uri $uri/ /index.html;
}
}

12730
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "routeros-backup-manager-next-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"
},
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"@ngx-translate/core": "^15.0.0",
"@ngx-translate/http-loader": "^8.0.0",
"primeicons": "^7.0.0",
"primeng": "^17.18.0",
"rxjs": "^7.8.1",
"tslib": "^2.6.2",
"zone.js": "^0.14.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.0",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"typescript": "^5.4.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,146 @@
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: '/routers', 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.settings', link: '/settings', icon: 'pi pi-cog', exact: false },
{ label: 'nav.logs', link: '/logs', icon: 'pi pi-history', exact: false },
{ label: 'nav.switchosBeta', link: '/switchos-beta', icon: 'pi pi-sitemap', exact: false },
{ label: 'nav.changePassword', link: '/change-password', icon: 'pi pi-lock', 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('/routers/')) {
this.pageLabel = 'routers.detailTitle';
return;
}
if (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('/switchos-beta')) {
this.pageLabel = 'switchosBeta.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';
import { SwosBetaPageComponent } from './features/swos-beta/swos-beta-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: 'routers', canActivate: [authGuard], component: RoutersPageComponent },
{ path: 'routers/:id', canActivate: [authGuard], component: RouterDetailPageComponent },
{ 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: 'switchos-beta', canActivate: [authGuard], component: SwosBetaPageComponent },
{ 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,68 @@
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';
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,25 @@
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);
document.body.classList.toggle('dark-theme', mode === 'dark');
localStorage.setItem(this.key, mode);
}
}

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,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: ['admin', Validators.required],
password: ['admin', 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="warning" 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="warning" 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-dropdown [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value" (onChange)="load()"></p-dropdown>
</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-dropdown [options]="compareOptions" [(ngModel)]="compareLeftId" optionLabel="label" optionValue="value" [placeholder]="'files.pickOlder' | translate"></p-dropdown>
<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-dropdown [options]="compareOptions" [(ngModel)]="compareRightId" optionLabel="label" optionValue="value" [placeholder]="'files.pickNewer' | translate"></p-dropdown>
<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 { DropdownModule } from 'primeng/dropdown';
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, DropdownModule, 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,186 @@
<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="warning" 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-dropdown [options]="typeOptions" [(ngModel)]="backupType" optionLabel="label" optionValue="value"></p-dropdown>
</span>
<span class="form-field">
<label>{{ 'files.routerLabel' | translate }}</label>
<p-dropdown [options]="routerOptions" [(ngModel)]="routerId" optionLabel="label" optionValue="value"></p-dropdown>
</span>
<span class="form-field">
<label>{{ 'files.sortLabel' | translate }}</label>
<p-dropdown [options]="sortOptions" [(ngModel)]="sortBy" optionLabel="label" optionValue="value"></p-dropdown>
</span>
<span class="form-field">
<label>{{ 'files.orderLabel' | translate }}</label>
<p-dropdown [options]="orderOptions" [(ngModel)]="order" optionLabel="label" optionValue="value"></p-dropdown>
</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-dropdown [options]="compareOptions" [(ngModel)]="compareLeftId" optionLabel="label" optionValue="value" [placeholder]="'files.pickOlder' | translate"></p-dropdown>
</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-dropdown [options]="compareOptions" [(ngModel)]="compareRightId" optionLabel="label" optionValue="value" [placeholder]="'files.pickNewer' | translate"></p-dropdown>
</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">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' : 'warning'"></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">{{ item.backup_type === 'export' ? '.rsc' : '.backup' }}</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'" 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,439 @@
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 { DropdownModule } from 'primeng/dropdown';
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';
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, DropdownModule, 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;
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));
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.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) {
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)}`;
}
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,164 @@
<app-page-header
[eyebrow]="'routers.profileEyebrow' | translate"
[title]="routerItem?.name || ('routers.detailTitle' | translate)"
[subtitle]="routerItem ? routerItem.host + ':' + routerItem.port + ' · ' + routerItem.ssh_user : ('routers.detailSubtitle' | translate)"
>
<div header-actions class="header-actions-row">
<button 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]="'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.exportsLabel' | translate" [value]="exportBackups.length" [hint]="'routers.exportsLabelHint' | translate" [tag]="'files.exportType' | translate" severity="success" icon="pi pi-file-export" iconClass="icon-emerald"></app-stat-card>
<app-stat-card [label]="'routers.binaryLabel' | translate" [value]="binaryBackups.length" [hint]="'routers.binaryLabelHint' | translate" [tag]="'files.binaryType' | translate" severity="warning" 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?.ssh_user || '-'" [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>
<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">
<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">
<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>
<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 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,286 @@
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';
interface BackupItem {
id: number;
file_name: string;
backup_type: 'export' | 'binary';
created_at: string;
}
interface ConnectionSnapshot {
success: boolean;
tested_at: string;
hostname: string;
model: string;
version?: string | null;
uptime: string;
error?: string | 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: any;
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 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;
}
ngOnInit() {
this.routerId = Number(this.route.snapshot.paramMap.get('id'));
this.load();
}
load() {
this.api.http.get(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem: any) => {
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) {
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) {
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(['/routers']),
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<any>(`${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: any): 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
};
}
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,
};
}
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,88 @@
<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>{{ keyCount }}</strong>
<span>{{ 'routers.summaryKeyAccess' | translate }}</span>
</div>
<div class="inline-summary__divider"></div>
<div class="inline-summary__item">
<strong>{{ passwordCount }}</strong>
<span>{{ 'routers.summaryPasswordAccess' | 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">{{ 'routers.routerOsTarget' | translate }}</small>
</td>
<td>
<div class="table-primary">{{ routerItem.host }}:{{ routerItem.port }}</div>
<small class="table-secondary">{{ routerItem.ssh_user }}</small>
</td>
<td>
<div class="inline-tags">
<p-tag [value]="routerItem.ssh_password ? ('routers.passwordMode' | translate) : ('routers.noPassword' | translate)" [severity]="routerItem.ssh_password ? 'warning' : 'secondary'"></p-tag>
<p-tag [value]="routerItem.ssh_key ? ('routers.keyMode' | translate) : ('routers.noKey' | translate)" [severity]="routerItem.ssh_key ? 'success' : 'secondary'"></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" [header]="dialogTitle" [style]="{ width: '640px' }" styleClass="router-dialog">
<form [formGroup]="form" (ngSubmit)="save()" class="form-grid-2">
<span class="form-field">
<label>{{ 'routers.name' | translate }}</label>
<input pInputText formControlName="name" placeholder="core-router-waw" />
</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="22" />
</span>
<span class="form-field">
<label>{{ 'routers.sshUser' | translate }}</label>
<input pInputText formControlName="ssh_user" placeholder="admin" />
</span>
<span class="form-field form-field--full">
<label>{{ 'routers.sshPassword' | translate }}</label>
<input pInputText formControlName="ssh_password" [placeholder]="'routers.optionalPassword' | translate" />
</span>
<span class="form-field form-field--full">
<label>{{ 'routers.sshPrivateKey' | translate }}</label>
<textarea pInputTextarea formControlName="ssh_key" rows="7" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
</span>
<div class="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,131 @@
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 { InputTextareaModule } from 'primeng/inputtextarea';
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';
interface RouterItem {
id: number;
name: string;
host: string;
port: number;
ssh_user: string;
ssh_password?: string;
ssh_key?: string;
}
@Component({
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, ButtonModule, DialogModule, InputTextModule, InputTextareaModule, 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 form = this.fb.nonNullable.group({
name: ['', Validators.required],
host: ['', Validators.required],
port: [22, Validators.required],
ssh_user: ['admin', Validators.required],
ssh_password: '',
ssh_key: ''
});
get dialogTitle(): string {
return this.ui.instant(this.editingId ? 'routers.editDialogTitle' : 'routers.createDialogTitle');
}
get passwordCount(): number {
return this.routers.filter((item) => !!item.ssh_password).length;
}
get keyCount(): number {
return this.routers.filter((item) => !!item.ssh_key).length;
}
ngOnInit() {
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: '', 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,
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 request$ = this.editingId
? this.api.http.put(`${this.api.baseUrl}/routers/${this.editingId}`, this.form.getRawValue())
: this.api.http.post(`${this.api.baseUrl}/routers`, this.form.getRawValue());
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(['/routers', id]);
}
}

View File

@@ -0,0 +1,308 @@
<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-dropdown [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.export.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
</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-dropdown [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.export.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
</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-dropdown [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.binary.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
</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-dropdown [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.binary.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
</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-dropdown [appendTo]="'body'" [options]="scheduleModeOptions" [(ngModel)]="scheduleEditors.retention.mode" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
</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-dropdown [appendTo]="'body'" [options]="weekdayOptions" [(ngModel)]="scheduleEditors.retention.weekday" [ngModelOptions]="{ standalone: true }" optionLabel="label" optionValue="value"></p-dropdown>
</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-dropdown [appendTo]="'body'" [autoDisplayFirst]="false" formControlName="preferred_language" [options]="languageOptions" optionLabel="label" optionValue="value" (onChange)="previewLanguage($event.value)"></p-dropdown>
</span>
<span class="form-field">
<label>{{ 'settings.fontFamily' | translate }}</label>
<p-dropdown [appendTo]="'body'" formControlName="preferred_font" [options]="fontOptions" optionLabel="label" optionValue="value" (onChange)="previewFont()"></p-dropdown>
</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">
<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
pInputTextarea
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>
</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,490 @@
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 { DropdownModule } from 'primeng/dropdown';
import { InputTextModule } from 'primeng/inputtext';
import { InputTextareaModule } from 'primeng/inputtextarea';
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;
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, DropdownModule, InputTextModule, InputTextareaModule, 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: '',
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: '',
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,
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="warning" [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="warning" [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,15 @@
<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" [value]="language.current()" (change)="changeLanguage($event)">
<option *ngFor="let option of languageOptions" [value]="option.code">{{ option.label }}</option>
</select>
</label>
</div>

View File

@@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { ButtonModule } from 'primeng/button';
import { AppLanguage, LanguageService } from '../../core/services/language.service';
import { ThemeService } from '../../core/services/theme.service';
@Component({
selector: 'app-auth-toolbar',
standalone: true,
imports: [CommonModule, ButtonModule],
templateUrl: './auth-toolbar.component.html'
})
export class AuthToolbarComponent {
readonly theme = inject(ThemeService);
readonly language = inject(LanguageService);
get languageOptions() {
const current = this.language.current();
return [
{ code: 'no', label: `${current === 'no' ? '✓ ' : ''}🇳🇴 Norsk` },
{ code: 'es', label: `${current === 'es' ? '✓ ' : ''}🇪🇸 Español` },
{ code: 'pl', label: `${current === 'pl' ? '✓ ' : ''}🇵🇱 Polski` },
{ code: 'en', label: `${current === 'en' ? '✓ ' : ''}🇬🇧 English` }
];
}
changeLanguage(event: Event) {
this.language.set((event.target as HTMLSelectElement).value 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,35 @@
<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" [value]="lang" (change)="onLanguageSelect($event)">
<option *ngFor="let option of displayLanguages" [value]="option.code">{{ 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,45 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
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, 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();
}
get displayLanguages(): TopbarLanguageOption[] {
return this.languages.map((option) => ({
...option,
label: `${option.flag} ${option.label}${option.code === this.lang ? ' ✓' : ''}`
}));
}
onLanguageSelect(event: Event) {
const value = (event.target as HTMLSelectElement).value;
this.languageChange.emit(value);
}
}

View File

@@ -0,0 +1,10 @@
<div class="page-header">
<div>
<div class="page-header__eyebrow" *ngIf="eyebrow">{{ eyebrow }}</div>
<h1 class="page-header__title">{{ title }}</h1>
<p class="page-header__subtitle" *ngIf="subtitle">{{ subtitle }}</p>
</div>
<div class="page-header__actions">
<ng-content select="[header-actions]"></ng-content>
</div>
</div>

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-page-header',
standalone: true,
imports: [CommonModule],
templateUrl: './page-header.component.html'
})
export class PageHeaderComponent {
@Input({ required: true }) title = '';
@Input() subtitle = '';
@Input() eyebrow = '';
}

Some files were not shown because too many files have changed in this diff Show More