Compare commits

..

2 Commits

Author SHA1 Message Date
gru
1c1e0552bf Update README.md 2025-02-23 00:44:31 +01:00
gru
77b9bce9f8 Update README.md 2025-02-23 00:25:13 +01:00
27 changed files with 502 additions and 1218 deletions

View File

@@ -1,11 +0,0 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "run_waitress.py"]

107
README.md
View File

@@ -1,101 +1,20 @@
# RouterOS Backup Manager
# RouterOS backup system
RouterOS Backup Manager to aplikacja Flask umożliwiająca zarządzanie kopiami zapasowymi urządzeń Mikrotik RouterOS. Aplikacja pozwala na eksport konfiguracji, tworzenie backupów binarnych, ich przechowywanie, porównywanie oraz przywracanie.
# Instalation:
- clone (to ex. /opt/routeros_backup)
- create venv
- install requirements via pip
- copy systemd service (routeros_backup.service)
## 🔧 Instalacja
# Start
- systemctl start routeros_backup.service
- go to http://IPADDRESS:5581
### 1. Klonowanie repozytorium
```sh
git clone https://gitea.linuxiarz.pl/gru/routeros_backup.git
cd routeros_backup
```
# Register, Login
### 2. Tworzenie i aktywacja środowiska wirtualnego (opcjonalnie)
```sh
python3 -m venv venv
source venv/bin/activate # Linux/macOS
venv\Scripts\activate # Windows
```
# Configure devices, keys, backups, crons
### 3. Instalacja zależności
```sh
pip install -r requirements.txt
```
### 4. Uruchomienie aplikacji lokalnie
```sh
python run_waitress.py
```
Aplikacja będzie dostępna pod adresem: `http://127.0.0.1:5581/`
## Authors
---
## 📦 Uruchamianie w Dockerze
1. **Zbudowanie obrazu Docker**
```sh
docker-compose build
```
2. **Uruchomienie kontenera**
```sh
docker-compose up -d
```
Aplikacja uruchomi się na porcie `5581`.
---
## 📚 Funkcjonalności
- 🔐 System użytkowników (rejestracja, logowanie, zmiana hasła)
- 📡 Połączenie SSH do routerów MikroTik
- 🛠 Eksport konfiguracji i tworzenie backupów binarnych
- 🕵️‍♂️ Porównywanie backupów (`diff`)
- 📩 Powiadomienia e-mail oraz Pushover
- 📅 Harmonogram automatycznych backupów (APScheduler)
- 🧹 Automatyczne czyszczenie starych backupów i logów
- 🚀 Obsługa przez interfejs webowy
---
## ⚙️ Konfiguracja
### Zmiana ustawień
Plik `app.py` zawiera konfigurację bazy danych oraz inne ustawienia aplikacji:
```python
app.config['SECRET_KEY'] = 'super-secret-key'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///backup_routeros.db'
```
### 📬 Konfiguracja SMTP (E-mail)
Aby skonfigurować powiadomienia e-mail, wprowadź dane w sekcji ustawień:
- Serwer SMTP
- Login/hasło SMTP
- Port (587 dla TLS, 465 dla SSL)
### 📲 Powiadomienia Pushover
Aby włączyć powiadomienia Pushover, uzupełnij `pushover_token` oraz `pushover_userkey` w ustawieniach.
---
## 🔍 API & Health Check
Aplikacja zawiera endpoint `/health`, który zwraca status bazy danych:
```sh
curl http://127.0.0.1:5581/health
```
Przykładowa odpowiedź:
```json
{
"status": "ok",
"timestamp": "2024-02-26T12:34:56Z"
}
```
---
## 🚀 Autor i licencja
Projekt stworzony przez Mateusz Grusczyński @linuxiarz.pl - dostępny na licencji MIT.
- [@linuxiarz.pl]

222
app.py
View File

@@ -9,16 +9,18 @@ import re
import smtplib
import shutil
import socket
import hashlib
from datetime import datetime
from ftplib import FTP
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
from flask import jsonify
from flask import Flask
import shutil
from datetime import datetime
from difflib import HtmlDiff
import difflib
from datetime import datetime, timedelta
from sqlalchemy import text
@@ -27,7 +29,7 @@ from flask import (
url_for, session, flash, send_file
)
from flask_sqlalchemy import SQLAlchemy
from passlib.context import CryptContext
from passlib.hash import bcrypt
#from flask_wtf.csrf import CSRFProtect
from apscheduler.schedulers.background import BackgroundScheduler
@@ -56,18 +58,14 @@ os.makedirs(DATA_DIR, exist_ok=True)
###############################################################################
# Modele bazy danych
###############################################################################
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
def set_password(self, password):
self.password_hash = pwd_context.hash(password)
def check_password(self, password):
return pwd_context.verify(password, self.password_hash)
return bcrypt.verify(password, self.password_hash)
class Router(db.Model):
__tablename__ = 'routers'
@@ -89,8 +87,6 @@ class Backup(db.Model):
file_path = db.Column(db.String(255), nullable=False) # Ścieżka do pliku
backup_type = db.Column(db.String(50), default='export') # 'export' lub 'binary'
created_at = db.Column(db.DateTime, default=datetime.utcnow)
checksum = db.Column(db.String(64), nullable=True)
class OperationLog(db.Model):
__tablename__ = 'operation_logs'
__table_args__ = {'extend_existing': True} # Zapobiega redefinicji tabeli
@@ -116,8 +112,6 @@ class GlobalSettings(db.Model):
smtp_login = db.Column(db.String(255), nullable=True)
smtp_password = db.Column(db.String(255), nullable=True)
smtp_notifications_enabled = db.Column(db.Boolean, default=False)
log_retention_days = db.Column(db.Integer, default=7)
recipient_email = db.Column(db.String(255), nullable=True)
###############################################################################
# Inicjalizacja bazy
@@ -172,13 +166,6 @@ def load_pkey(ssh_key_str: str):
except Exception as e:
raise ValueError("Nie udało się załadować klucza SSH. Sprawdź, czy klucz jest poprawny i nie jest zaszyfrowany") from e
def compute_checksum(file_path):
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256.update(chunk)
return sha256.hexdigest()
###############################################################################
# Funkcje SSH
###############################################################################
@@ -242,17 +229,12 @@ def ssh_backup(router: Router, backup_name: str) -> str:
print(f"[DEBUG] ssh_backup -> local_path={local_path}")
return local_path
def ssh_upload_backup(router: Router, local_backup_path: str, expected_checksum: str = None):
# Weryfikacja sumy kontrolnej, jeśli podana
if expected_checksum:
local_checksum = compute_checksum(local_backup_path)
if local_checksum != expected_checksum:
raise ValueError("Suma kontrolna pliku nie zgadza się plik może być uszkodzony.")
def ssh_upload_backup(router: Router, local_backup_path: str):
print(f"[DEBUG] ssh_upload_backup -> router id={router.id}, local_backup_path={local_backup_path}")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# Wybór klucza: indywidualny lub globalny
# Używamy indywidualnego klucza, a jeśli nie ma, to globalnego
key_source = router.ssh_key if router.ssh_key and router.ssh_key.strip() else get_settings().global_ssh_key
if key_source and key_source.strip():
try:
@@ -263,10 +245,8 @@ def ssh_upload_backup(router: Router, local_backup_path: str, expected_checksum:
raise e
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)
password=router.ssh_password, timeout=10, allow_agent=False, look_for_keys=False, banner_timeout=10)
# Otwieramy sesję SFTP, przesyłamy plik, a następnie zamykamy połączenie
sftp = client.open_sftp()
remote_file = os.path.basename(local_backup_path)
sftp.put(local_backup_path, remote_file)
@@ -274,7 +254,6 @@ def ssh_upload_backup(router: Router, local_backup_path: str, expected_checksum:
client.close()
print(f"[DEBUG] ssh_upload_backup -> przesłano {local_backup_path} do routera")
def ssh_test_connection(router: Router) -> dict:
"""Testuje połączenie z routerem i zwraca informacje: model, uptime, hostname."""
client = paramiko.SSHClient()
@@ -397,19 +376,23 @@ def send_mail_with_attachment(smtp_host, smtp_port, smtp_user, smtp_pass, to_add
print("SMTP not properly configured, skipping email sending.")
return False
try:
# Utwórz wiadomość typu alternative (obsługuje plain text i HTML)
msg = MIMEMultipart("alternative")
msg["From"] = smtp_user
msg["To"] = to_address
msg["Subject"] = subject
# Dodaj część tekstową
part1 = MIMEText(plain_body, "plain")
msg.attach(part1)
# Jeśli nie podano wersji HTML, wygeneruj domyślny szablon
if html_body is None:
html_body = get_email_template(subject, plain_body)
part2 = MIMEText(html_body, "html")
msg.attach(part2)
# Dodaj załącznik, jeśli podany i istnieje
if attachment_path and os.path.isfile(attachment_path):
with open(attachment_path, "rb") as attachment:
part = MIMEBase("application", "octet-stream")
@@ -418,19 +401,10 @@ def send_mail_with_attachment(smtp_host, smtp_port, smtp_user, smtp_pass, to_add
part.add_header("Content-Disposition", f"attachment; filename={os.path.basename(attachment_path)}")
msg.attach(part)
if smtp_port == 465:
server = smtplib.SMTP_SSL(smtp_host, smtp_port)
server.login(smtp_user, smtp_pass)
server.send_message(msg)
server.quit()
else:
server = smtplib.SMTP(smtp_host, smtp_port)
server.ehlo()
if smtp_port == 587:
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
server.quit()
return True
except Exception as e:
print("Mail error_send_mail_with_attachment:", e)
@@ -467,13 +441,12 @@ def notify(settings: GlobalSettings, message: str, success: bool):
settings.smtp_login and settings.smtp_login.strip() and
settings.smtp_password and settings.smtp_password.strip()):
try:
to_address = settings.recipient_email.strip() if settings.recipient_email and settings.recipient_email.strip() else settings.smtp_login.strip()
send_mail_with_attachment(
smtp_host=settings.smtp_host.strip(),
smtp_port=settings.smtp_port,
smtp_user=settings.smtp_login.strip(),
smtp_pass=settings.smtp_password.strip(),
to_address=to_address,
to_address=settings.smtp_login.strip(),
subject="RouterOS Backup Notification",
plain_body=message
)
@@ -518,10 +491,10 @@ def scheduled_auto_backup():
db.session.add(b)
db.session.commit()
notify(s, f"Auto-export dla routera {r.name} OK", True)
log_operation(f"Automatyczny export dla routera {r.name} wykonany pomyślnie at {datetime.utcnow()}.")
log_operation(f"Automatyczny eksport dla routera {r.name} wykonany pomyślnie at {datetime.utcnow()}.")
except Exception as e:
notify(s, f"Auto-export dla routera {r.name} FAILED: {e}", False)
log_operation(f"Automatyczny export dla routera {r.name} FAILED at {datetime.utcnow()}: {e}")
log_operation(f"Automatyczny eksport dla routera {r.name} FAILED at {datetime.utcnow()}: {e}")
def scheduled_auto_binary_backup():
with app.app_context():
@@ -531,8 +504,7 @@ def scheduled_auto_binary_backup():
try:
backup_name = f"{r.name}_{r.id}_{datetime.now():%Y%m%d_%H%M%S}"
local_path = ssh_backup(r, backup_name)
checksum = compute_checksum(local_path)
b = Backup(router_id=r.id, file_path=local_path, backup_type='binary', checksum=checksum)
b = Backup(router_id=r.id, file_path=local_path, backup_type='binary')
db.session.add(b)
db.session.commit()
notify(s, f"Auto-binary backup dla routera {r.name} OK", True)
@@ -596,19 +568,6 @@ def schedule_auto_export_job():
cron_used = s.export_cron if s.export_cron else "0 */12 * * *"
print(f"[DEBUG] schedule_auto_export_job -> cron_schedule={cron_used}")
def cleanup_old_logs():
with app.app_context():
s = get_settings()
cutoff_date = datetime.utcnow() - timedelta(days=s.log_retention_days)
old_logs = OperationLog.query.filter(OperationLog.timestamp < cutoff_date).all()
deleted_count = len(old_logs)
for log in old_logs:
db.session.delete(log)
db.session.commit()
log_operation(f"Automatyczna retencja logów: usunięto {deleted_count} logów starszych niż {s.log_retention_days} dni.")
###############################################################################
# Konfiguracja APScheduler - harmonogram zadań
###############################################################################
@@ -621,8 +580,8 @@ scheduler = BackgroundScheduler()
# Dodajemy z unikalnymi ID, co ułatwia re-schedulowanie
scheduler.add_job(func=cleanup_old_backups, trigger='interval', days=1, id="cleanup_job")
scheduler.add_job(func=scheduled_auto_backup, trigger='interval', days=1, id="auto_backup_job")
scheduler.start()
scheduler.start()
# Sprzątanie przy zamykaniu
atexit.register(lambda: scheduler.shutdown())
@@ -643,7 +602,6 @@ def reschedule_jobs():
schedule_auto_export_job()
schedule_retention_job()
schedule_auto_binary_backup_job()
scheduler.add_job(func=cleanup_old_logs, trigger='interval', days=1, id="cleanup_logs_job", replace_existing=True)
###############################################################################
# Filtr Jinja2 - basename
@@ -691,19 +649,6 @@ def get_data_folder_size():
pass
return total_size
@app.template_global()
def bootstrap_alert_category(cat):
mapping = {
"error": "danger",
"fail": "danger",
"warn": "warning",
"warning": "warning",
"ok": "success",
"success": "success",
"info": "info"
}
return mapping.get(cat.lower(), "info")
###############################################################################
# ROUTES
###############################################################################
@@ -775,20 +720,19 @@ def advanced_schedule():
s = get_settings()
if request.method == 'POST':
s.retention_cron = request.form.get('retention_cron', '').strip()
s.binary_cron = request.form.get('binary_cron', '').strip()
s.binary_cron = request.form.get('binary_cron', '').strip() # nowe pole
s.export_cron = request.form.get('export_cron', '').strip()
s.backup_retention_days = int(request.form.get('backup_retention_days', s.backup_retention_days))
s.log_retention_days = int(request.form.get('log_retention_days', s.log_retention_days))
# Checkbox: jeśli nie jest zaznaczony, nie pojawi się w formularzu, więc ustawiamy na False
s.enable_auto_export = True if request.form.get('enable_auto_export') == 'on' else False
db.session.commit()
reschedule_jobs() # Aktualizacja harmonogramu zadań
flash("Ustawienia harmonogramu zostały zapisane.")
reschedule_jobs() # Aktualizuje harmonogram zadań
flash("Zaawansowane ustawienia harmonogramu zostały zapisane.")
return redirect(url_for('advanced_schedule'))
return render_template('advanced_schedule.html', settings=s)
@app.route('/toggle_dark_mode')
def toggle_dark_mode():
current_mode = session.get('dark_mode', True)
current_mode = session.get('dark_mode', False)
session['dark_mode'] = not current_mode
return redirect(request.referrer or url_for('index'))
@@ -835,6 +779,8 @@ def routers_list():
routers = Router.query.filter_by(owner_id=user.id).order_by(Router.created_at.desc()).all()
return render_template('routers.html', user=user, routers=routers)
@app.route('/routers/add', methods=['GET','POST'])
@login_required
def add_router():
@@ -899,8 +845,8 @@ def router_export(router_id):
notify(get_settings(), f"Export {router.name} OK", True)
log_operation(f"Export wykonany dla routera {router.name} at {datetime.utcnow()}")
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return {"status": "success", "message": "export zakończony."}
flash("Export zakończony.")
return {"status": "success", "message": "Eksport zakończony."}
flash("Eksport zakończony.")
except Exception as e:
notify(get_settings(), f"Export {router.name} FAIL: {e}", False)
log_operation(f"Export dla routera {router.name} FAILED: {e}")
@@ -920,8 +866,7 @@ def router_backup(router_id):
try:
backup_name = f"{router.name}_{router.id}_{datetime.now():%Y%m%d_%H%M%S}"
local_path = ssh_backup(router, backup_name)
checksum = compute_checksum(local_path)
b = Backup(router_id=router.id, file_path=local_path, backup_type='binary', checksum=checksum)
b = Backup(router_id=router.id, file_path=local_path, backup_type='binary')
db.session.add(b)
db.session.commit()
notify(get_settings(), f"Backup {router.name} OK", True)
@@ -944,23 +889,14 @@ def upload_backup(router_id, backup_id):
b = Backup.query.filter_by(id=backup_id, router_id=router.id, backup_type='binary').first()
if not b:
flash("Nie znaleziono backupu binarnego.")
#return redirect(url_for('router_details', router_id=router.id))
next_url = request.form.get('next') or request.referrer or url_for('dashboard')
return redirect(next_url)
local_checksum = compute_checksum(b.file_path)
if b.checksum != local_checksum:
flash("Błąd: suma kontrolna backupu nie zgadza się plik może być uszkodzony.")
#return redirect(url_for('router_details', router_id=router.id))
next_url = request.form.get('next') or request.referrer or url_for('dashboard')
return redirect(next_url)
return redirect(url_for('router_details', router_id=router.id))
try:
ssh_upload_backup(router, b.file_path, expected_checksum=b.checksum)
ssh_upload_backup(router, b.file_path)
log_operation(f"Backup {os.path.basename(b.file_path)} wgrany do routera {router.name} at {datetime.utcnow()}")
flash("Plik backupu wgrany do routera.")
except Exception as e:
flash(f"Błąd wgrywania: {e}")
#return redirect(url_for('router_details', router_id=router.id))
next_url = request.form.get('next') or request.referrer or url_for('dashboard')
return redirect(next_url)
@@ -1028,6 +964,7 @@ def export_all_routers():
flash(" | ".join(messages))
return redirect(url_for('dashboard'))
# Nowa podstrona: diff selector
@app.route('/diff_selector', methods=['GET', 'POST'])
@login_required
def diff_selector():
@@ -1047,11 +984,13 @@ def diff_selector():
def all_files():
user = get_current_user()
query = Backup.query.join(Router).filter(Router.owner_id == user.id)
# Filtrowanie wyszukiwanie po nazwie pliku (zastosowanie filtru "basename")
search = request.args.get('search', '')
if search:
query = query.filter(Backup.file_path.ilike(f"%{search}%"))
# Sortowanie sort_by i order
sort_by = request.args.get('sort_by', 'created_at')
order = request.args.get('order', 'desc')
if sort_by not in ['created_at', 'file_path']:
@@ -1081,7 +1020,7 @@ def view_export(backup_id):
flash("Brak dostępu do backupu.")
return redirect(url_for('all_files'))
if b.backup_type != 'export':
flash("Wybrany backup nie jest plikiem exportu.")
flash("Wybrany backup nie jest plikiem eksportu.")
return redirect(url_for('all_files'))
try:
with open(b.file_path, 'r', encoding='utf-8') as f:
@@ -1105,10 +1044,10 @@ def send_export_email(backup_id):
flash("Nie skonfigurowano ustawień SMTP w panelu.")
return redirect(url_for('settings_view'))
subject = f"RouterOS Export: {os.path.basename(b.file_path)}"
body = f"Przesyłam export {os.path.basename(b.file_path)} z routera {b.router.name}."
body = f"Przesyłam eksport {os.path.basename(b.file_path)} z routera {b.router.name}."
if send_mail_with_attachment(s.smtp_host, s.smtp_port, s.smtp_login, s.smtp_password,
s.smtp_login, subject, body, b.file_path):
flash("Wysłano export mailem.")
flash("Wysłano eksport mailem.")
else:
flash("Błąd wysyłki mailowej.")
#return redirect(url_for('router_details', router_id=b.router_id))
@@ -1154,9 +1093,8 @@ def settings_view():
s.smtp_port = int(request.form.get('smtp_port', '587'))
s.smtp_login = request.form.get('smtp_login', '')
s.smtp_password = request.form.get('smtp_password', '')
s.recipient_email = request.form.get('recipient_email', '')
db.session.commit()
#reschedule_jobs() # Aktualizacja zadań zadania dotyczące backupu/CRON zostaną teraz sterowane z /advanced_schedule
reschedule_jobs() # Aktualizacja zadań zadania dotyczące backupu/CRON zostaną teraz sterowane z /advanced_schedule
flash("Zapisano ustawienia.")
return redirect(url_for('settings_view'))
return render_template('settings.html', settings=s)
@@ -1270,6 +1208,7 @@ def diff_view(backup_id1, backup_id2):
lineterm=''
))
diff_text = "\n".join(diff_lines)
return render_template('diff.html', diff_text=diff_text, backup1=b1, backup2=b2)
@app.route('/routers/all_backup', methods=['POST'])
@@ -1362,7 +1301,7 @@ def change_password():
flash("Nowe hasło i potwierdzenie nie są zgodne.")
return redirect(url_for('change_password'))
user.password_hash = pwd_context.hash(new_password)
user.password_hash = bcrypt.hash(new_password)
db.session.commit()
flash("Hasło zostało zmienione pomyślnie.")
return redirect(url_for('dashboard'))
@@ -1381,84 +1320,17 @@ def test_connection(router_id):
except Exception as e:
flash(f"Błąd testu połączenia: {e}")
return redirect(url_for('routers_list'))
# Jeśli wywołanie zawiera parametr modal=1, zwracamy widok dla modalu
if request.args.get("modal") == "1":
return render_template("test_connection_modal.html", router=router, result=result)
return render_template("test_connection.html", router=router, result=result)
@app.route('/logs')
@login_required
def logs_page():
logs = OperationLog.query.order_by(OperationLog.timestamp.desc()).all()
return render_template('logs.html', logs=logs)
@app.route('/logs/delete', methods=['POST'])
@login_required
def delete_old_logs():
try:
delete_days = int(request.form.get('delete_days', 0))
except ValueError:
flash("Podana wartość jest nieprawidłowa.")
return redirect(url_for('logs_page'))
if delete_days < 1:
flash("Podaj wartość większą lub równą 1.")
return redirect(url_for('logs_page'))
cutoff_date = datetime.utcnow() - timedelta(days=delete_days)
old_logs = OperationLog.query.filter(OperationLog.timestamp < cutoff_date).all()
deleted_count = 0
for log in old_logs:
db.session.delete(log)
deleted_count += 1
db.session.commit()
flash(f"Usunięto {deleted_count} logów starszych niż {delete_days} dni.")
return redirect(url_for('logs_page'))
@app.route('/test_email', methods=['POST'])
@login_required
def test_email():
s = get_settings()
# Sprawdzamy, czy ustawienia SMTP są skonfigurowane
if not (s.smtp_host and s.smtp_login and s.smtp_password):
flash("Brak skonfigurowanych ustawień SMTP.")
return redirect(url_for('settings_view'))
subject = "Testowy e-mail z RouterOS Backup"
body = "To jest testowa wiadomość e-mail wysłana z systemu RouterOS Backup."
# Używamy recipient_email, jeśli jest ustawiony, w przeciwnym razie smtp_login
to_address = s.recipient_email.strip() if s.recipient_email and s.recipient_email.strip() else s.smtp_login.strip()
success = send_mail_with_attachment(
smtp_host=s.smtp_host,
smtp_port=s.smtp_port,
smtp_user=s.smtp_login,
smtp_pass=s.smtp_password,
to_address=to_address,
subject=subject,
plain_body=body
)
if success:
flash("Testowy e-mail został wysłany.")
else:
flash("Wysyłka testowego e-maila nie powiodła się.")
return redirect(url_for('settings_view'))
@app.route('/test_pushover', methods=['POST'])
@login_required
def test_pushover():
s = get_settings()
# Sprawdzamy, czy ustawienia Pushover są skonfigurowane
if not (s.pushover_token and s.pushover_userkey):
flash("Brak skonfigurowanych ustawień Pushover.")
return redirect(url_for('settings_view'))
message = "Testowe powiadomienie Pushover z systemu RouterOS Backup."
success = send_pushover(s.pushover_token, s.pushover_userkey, message)
if success:
flash("Testowe powiadomienie Pushover zostało wysłane.")
else:
flash("Wysyłka testowego powiadomienia Pushover nie powiodła się.")
return redirect(url_for('settings_view'))
if __name__ == '__main__':
with app.app_context():
reschedule_jobs()
scheduler = BackgroundScheduler()
schedule_retention_job()
schedule_auto_export_job()
schedule_auto_binary_backup_job()
scheduler.start()
atexit.register(lambda: scheduler.shutdown())
app.run(host='0.0.0.0', port=5581, use_reloader=False, debug=True)

View File

@@ -1,15 +0,0 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: routeros_backup
ports:
- "5581:5581"
environment:
- FLASK_ENV=production
restart: unless-stopped
volumes:
- ./data:/data

7
gunicorn_config.py Normal file
View File

@@ -0,0 +1,7 @@
bind = "0.0.0.0:81"
workers = 4
timeout = 120
server_header = False
def on_starting(server):
server.cfg.server_header = False
server.log.info("Server header disabled")

View File

@@ -4,8 +4,7 @@ passlib
paramiko
APScheduler
requests
#gunicorn
gunicorn
flask_wtf
gevent
#croniter
waitress

View File

@@ -1,14 +1,18 @@
[Unit]
Description=RouterOS Backup Waitress Service
Description=RouterOS Backup Application
After=network.target
[Service]
#User=routeros
#Group=routeros
#User=www-data # Zmień na odpowiedniego użytkownika
#Group=www-data
WorkingDirectory=/opt/routeros_backup
ExecStart=/opt/routeros_backup/venv/bin/python3 /opt/routeros_backup/run_waitress.py
Environment="PATH=/opt/hosts_app/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Environment="FLASK_APP=app.py"
Environment="FLASK_ENV=production"
ExecStart=/opt/routeros_backup/venv/bin/gunicorn -c /opt/routeros_backup/gunicorn_config.py --worker-class gevent --keep-alive 10 app:app
Restart=always
Environment=PYTHONUNBUFFERED=1
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -1,9 +0,0 @@
from waitress import serve
from app import app, reschedule_jobs, scheduler
import atexit
with app.app_context():
reschedule_jobs()
atexit.register(lambda: scheduler.shutdown())
serve(app, host='0.0.0.0', port=5581, ident='', threads=4)

View File

@@ -1,21 +0,0 @@
#!/bin/bash
# Sprawdzenie, czy podman jest dostępny
if command -v podman &> /dev/null; then
COMPOSE_CMD="podman-compose"
echo "🟢 Wykryto Podman, używam podman-compose..."
else
COMPOSE_CMD="docker-compose"
echo "🔵 Podman nie znaleziony, używam docker-compose..."
fi
# Zatrzymanie i usunięcie kontenerów
$COMPOSE_CMD down
# Odbudowa obrazu
$COMPOSE_CMD build
# Uruchomienie w tle
$COMPOSE_CMD up -d
echo "✅ Aplikacja uruchomiona!"

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light">
<h4 class="mb-0">Dodaj nowe urządzenie</h4>
<div class="container mt-5">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">Dodaj nowe urządzenie</h2>
</div>
<div class="card-body">
<form method="POST">
@@ -24,16 +24,18 @@
<input type="text" class="form-control" id="ssh_user" name="ssh_user" required>
</div>
<div class="mb-3">
<label for="ssh_key" class="form-label"><b>Klucz prywatny</b></label><br>
<small>Wklej wraz z <code>-----BEGIN RSA PRIVATE KEY-----</code> i <code>-----END RSA PRIVATE KEY-----</code>. Jeśli pusty użyje klucza globalnego.</small>
<textarea class="form-control mt-2" id="ssh_key" name="ssh_key" rows="4"></textarea>
<label for="ssh_key" class="form-label">
<b>Klucz prywatny</b> | Wklej wraz z <code>-----BEGIN RSA PRIVATE KEY-----</code> i <code>-----END RSA PRIVATE KEY-----</code><br>
Pozostaw puste jeśli ten RouterOS będzie używał <a href="{{ url_for('settings_view') }}">klucza globalnego</a>
</label>
<textarea class="form-control" id="ssh_key" name="ssh_key" rows="4"></textarea>
</div>
<div class="mb-3">
<label for="ssh_password" class="form-label"><b>Hasło SSH</b></label><br>
<small>Jeśli jest klucz SSH lub klucz globalny, hasło może być ignorowane.</small>
<input type="password" class="form-control mt-2" id="ssh_password" name="ssh_password">
Jeśli podajesz klucz SSH lub zdefiniowany jest <a href="{{ url_for('settings_view') }}">klucz globalny</a>, to logowanie hasłem jest nieaktywne.
<input type="password" class="form-control" id="ssh_password" name="ssh_password">
</div>
<button type="submit" class="btn btn-primary">Dodaj urządzenie</button>
<button type="submit" class="btn btn-primary">Dodaj</button>
</form>
</div>
</div>

View File

@@ -1,61 +1,43 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light">
<h4 class="mb-0">Zaawansowane ustawienia harmonogramu</h4>
<div class="container mt-5">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">Zaawansowane ustawienia harmonogramu</h2>
</div>
<div class="card-body">
<form action="{{ url_for('advanced_schedule') }}" method="POST">
<div class="mb-3">
<label for="backup_retention_days" class="form-label">Próg retencji backupów (dni)</label>
<small class="text-muted d-block mb-2">Usuwanie danych starszych niż ustawione w progu.</small>
<input type="number" class="form-control" id="backup_retention_days" name="backup_retention_days" value="{{ settings.backup_retention_days }}">
</div>
<div class="mb-3">
<label for="log_retention_days" class="form-label">Próg retencji logów (dni)</label>
<input type="number" class="form-control" id="log_retention_days" name="log_retention_days" value="{{ settings.log_retention_days }}">
</div>
<div class="mb-3">
<label for="retention_cron" class="form-label">Harmonogram retencji <code>cron</code></label>
<label for="retention_cron" class="form-label">Harmonogram retencji (cron)</label>
<div class="input-group">
<input type="text" class="form-control" id="retention_cron" name="retention_cron" value="{{ settings.retention_cron }}">
<button type="button" class="btn btn-outline-secondary" onclick="openCronModal('retention_cron')">Generuj cron</button>
</div>
<small class="text-muted">Np. <code>0 */12 * * *</code> co 12 godzin</small>
<div class="form-text">Np. <code>0 */12 * * *</code> co 12 godzin</div>
</div>
<div class="mb-3">
<label for="binary_cron" class="form-label">Harmonogram kopii zapasowych binarnych <code>cron</code></label>
<label for="binary_cron" class="form-label">Harmonogram kopii zapasowych binarnych (cron)</label>
<div class="input-group">
<input type="text" class="form-control" id="binary_cron" name="binary_cron" value="{{ settings.binary_cron|default('') }}">
<button type="button" class="btn btn-outline-secondary" onclick="openCronModal('binary_cron')">Generuj cron</button>
</div>
<small class="text-muted">Np. <code>15 2 * * *</code> codziennie o 2:15</small>
<div class="form-text">Np. <code>15 2 * * *</code> codziennie o 2:15</div>
</div>
<div class="mb-3">
<label for="export_cron" class="form-label">Harmonogram exportów <code>cron</code></label>
<label for="export_cron" class="form-label">Harmonogram eksportów (cron)</label>
<div class="input-group">
<input type="text" class="form-control" id="export_cron" name="export_cron" value="{{ settings.export_cron }}">
<button type="button" class="btn btn-outline-secondary" onclick="openCronModal('export_cron')">Generuj cron</button>
</div>
<small class="text-muted">Np. <code>0 */12 * * *</code> co 12 godzin</small>
<div class="form-text">Np. <code>0 */12 * * *</code> co 12 godzin</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enable_auto_export" name="enable_auto_export" {% if settings.enable_auto_export %}checked{% endif %}>
<label class="form-check-label" for="enable_auto_export">Włącz automatyczny export</label>
<label class="form-check-label" for="enable_auto_export">Włącz automatyczny eksport</label>
</div>
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
</form>
</div>
<div class="card-footer text-center">
<small class="text-muted">Ustawienia zostaną zapisane i użyte przez cron.</small>
</div>
</div>
</div>
@@ -98,15 +80,18 @@
</div>
<script>
// Zmienna przechowująca ID pola, do którego ma być wpisane wyrażenie cron
var targetCronField = '';
function openCronModal(fieldId) {
targetCronField = fieldId;
// Wyzeruj wartości w modalu
document.getElementById('cron_minute').value = '*';
document.getElementById('cron_hour').value = '*';
document.getElementById('cron_day').value = '*';
document.getElementById('cron_month').value = '*';
document.getElementById('cron_dow').value = '*';
// Otwórz modal (przy użyciu Bootstrap 5)
var cronModal = new bootstrap.Modal(document.getElementById('cronModal'));
cronModal.show();
}
@@ -117,8 +102,10 @@
var day = document.getElementById('cron_day').value || '*';
var month = document.getElementById('cron_month').value || '*';
var dow = document.getElementById('cron_dow').value || '*';
var cronExpr = minute + ' ' + hour + ' ' + day + ' ' + month + ' ' + dow;
document.getElementById(targetCronField).value = cronExpr;
// Zamknij modal
var modalEl = document.getElementById('cronModal');
var modalInstance = bootstrap.Modal.getInstance(modalEl);
modalInstance.hide();

View File

@@ -3,8 +3,8 @@
<div class="container my-4">
<h2 class="text-center mb-4">Lista wszystkich backupów</h2>
<!-- Karta filtra -->
<div class="card mb-4 shadow-sm border-0">
<!-- Formularz filtrowania -->
<div class="card mb-4 shadow-sm">
<div class="card-body">
<form method="GET" action="{{ url_for('all_files') }}" class="row g-2">
<div class="col-md-4">
@@ -29,11 +29,11 @@
</div>
</div>
<!-- Karta tabeli backupów -->
<div class="card shadow-sm border-0 mb-4">
<!-- Tabela z backupami -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr>
<th style="width: 2%;"><input type="checkbox" id="select_all"></th>
@@ -63,23 +63,18 @@
<span class="badge bg-secondary">{{ file.backup_type }}</span>
{% endif %}
</td>
<td>
{% if file.backup_type == 'binary' %}
<span data-bs-toggle="tooltip" title="Checksum: {{ file.checksum }}">{{ file.file_path|basename }}</span>
{% else %}
{{ file.file_path|basename }}
{% endif %}
</td>
<td>{{ file.file_path|basename }}</td>
<td>{{ file.created_at.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td>{{ file.file_path|filesize }}</td>
<td>
<a href="{{ url_for('download_file', filename=file.file_path|basename) }}" class="btn btn-sm btn-info">
<a href="{{ url_for('download_file', filename=file.file_path|basename) }}" class="btn btn-lg btn-info">
<i class="bi bi-download"></i>
</a>
</td>
<td>
<form action="{{ url_for('send_by_email', backup_id=file.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-warning">
<input type="hidden" name="next" value="{{ url_for('all_files') }}">
<button type="submit" class="btn btn-lg btn-warning">
<i class="bi bi-envelope"></i>
</button>
</form>
@@ -87,7 +82,8 @@
<td>
{% if file.backup_type == 'binary' %}
<form action="{{ url_for('upload_backup', router_id=file.router.id, backup_id=file.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-secondary">
<input type="hidden" name="next" value="{{ url_for('all_files') }}">
<button type="submit" class="btn btn-lg btn-secondary">
<i class="bi bi-upload"></i>
</button>
</form>
@@ -97,7 +93,7 @@
</td>
<td>
{% if file.backup_type == 'export' %}
<a href="{{ url_for('view_export', backup_id=file.id) }}" class="btn btn-sm btn-outline-primary">
<a href="{{ url_for('view_export', backup_id=file.id) }}" class="btn btn-lg btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
{% else %}
@@ -106,7 +102,8 @@
</td>
<td>
<form action="{{ url_for('delete_backup', backup_id=file.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć backup?');">
<button type="submit" class="btn btn-sm btn-danger">
<input type="hidden" name="next" value="{{ url_for('all_files') }}">
<button type="submit" class="btn btn-lg btn-danger">
<i class="bi bi-trash"></i>
</button>
</form>
@@ -120,21 +117,22 @@
</div>
</div>
<!-- Formularz dla masowych akcji (zaznaczone pliki) -->
<!-- Formularz dla masowych akcji (jeden formularz) -->
<form id="mass_actions_form" action="{{ url_for('mass_actions') }}" method="POST" class="d-flex justify-content-end mb-4">
<button type="submit" name="action" value="download" class="btn btn-success me-2">
<i class="bi bi-file-earmark-zip"></i> Pobierz zip
<button type="submit" name="action" value="download" class="btn btn-lg btn-success me-2">
<i class="bi bi-file-earmark-zip"></i> Pobierz zip zaznaczonych
</button>
<button type="submit" name="action" value="delete" class="btn btn-danger" onclick="return confirm('Na pewno usunąć zaznaczone pliki?');">
<i class="bi bi-trash"></i> Usuń zaznaczone
<button type="submit" name="action" value="delete" class="btn btn-lg btn-danger" onclick="return confirm('Na pewno usunąć zaznaczone backupy?');">
<i class="bi bi-trash"></i> Usuń zaznaczone backupy
</button>
</form>
</div>
<script>
document.getElementById('select_all').addEventListener('change', function(e) {
var checkboxes = document.querySelectorAll('input[name="backup_id"]');
checkboxes.forEach(cb => cb.checked = e.target.checked);
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = e.target.checked;
}
});
</script>
{% endblock %}

View File

@@ -1,348 +1,63 @@
<!DOCTYPE html>
<html lang="pl" class="{% if session.get('dark_mode', True) %}dark-mode{% endif %}">
<html lang="pl" class="{% if session.dark_mode %}dark-mode{% endif %}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Backup RouterOS</title>
<title>Backup RouterOS App</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
<style>
/* 1) Poprawa kontrastu dla form-text w trybie ciemnym */
.dark-mode .form-text {
color: #ccc !important;
}
/* 2) Ogólne style trybu ciemnego */
.dark-mode body {
background-color: #121212;
background-color: #222;
color: #ffffff;
}
.dark-mode a,
.dark-mode a:hover {
.dark-mode a, .dark-mode a:hover {
color: #ddd;
}
/* 3) Nawigacja i menu w trybie ciemnym */
.dark-mode .navbar,
.dark-mode .navbar-nav,
.dark-mode .dropdown-menu {
.dark-mode .navbar, .dark-mode .table {
background-color: #333 !important;
color: #fff;
}
.dark-mode .navbar-nav .nav-link {
color: #ddd !important;
}
.dark-mode .navbar-nav .nav-link:hover {
color: #fff !important;
}
/* Dropdown :hover, :focus, active */
.dark-mode .dropdown-item:hover,
.dark-mode .dropdown-item:focus,
.dark-mode .dropdown-item.active {
background-color: #444 !important;
color: #fff !important;
}
/* 4) Tabele w trybie ciemnym */
.dark-mode .table {
background-color: #333 !important;
border-color: #444;
}
.dark-mode .table thead th {
background-color: #444;
color: #fff;
border: 1px solid #555;
}
.dark-mode .table tbody td {
color: #ddd;
border: 1px solid #555;
}
html.dark-mode table.table thead th {
background-color: #444 !important;
color: #fff !important;
border: 1px solid #555 !important;
}
html.dark-mode table.table tbody td {
background-color: #333 !important;
color: #ddd !important;
border: 1px solid #555 !important;
}
/* 5) Pola formularzy (input, select, textarea) w trybie ciemnym */
.dark-mode input,
.dark-mode textarea,
.dark-mode select {
background-color: #333;
color: #fff;
border: 1px solid #555;
}
.dark-mode ::placeholder {
color: #ccc;
}
/* 6) Przyciski w trybie ciemnym: .btn-warning, .btn-secondary, .btn-outline-dark */
.dark-mode .btn-warning {
background-color: #d39e00;
border-color: #b38600;
color: #fff;
}
.dark-mode .btn-warning:hover {
background-color: #e6aa00 !important;
border-color: #c98f00 !important;
color: #fff !important;
}
.dark-mode .btn-secondary {
background-color: #444;
border-color: #555;
color: #fff;
}
.dark-mode .btn-secondary:hover {
background-color: #555 !important;
border-color: #888888 !important;
color: #fff !important;
}
.dark-mode .btn-outline-dark:hover {
background-color: #444 !important;
border-color: #888888 !important;
color: #fff !important;
}
/* 7) Karty i bloki w trybie ciemnym */
.dark-mode .block,
.dark-mode .card {
background-color: #171717;
color: #fff;
}
/* 8) Stopka */
.dark-mode footer {
background-color: #1e1e1e !important;
color: #fff !important;
}
footer {
background-color: #f8f9fa;
color: #212529;
}
/* 9) Nadpisanie .card-header.bg-light w trybie ciemnym */
.dark-mode .card-header.bg-light {
background-color: #333 !important;
color: #fff !important;
}
/* 10) Style diff2html w trybie ciemnym */
.dark-mode .d2h-wrapper,
.dark-mode .d2h-file-header,
.dark-mode .d2h-file-info,
.dark-mode .d2h-file-diff,
.dark-mode .d2h-diff-table,
.dark-mode .d2h-code-line,
.dark-mode .d2h-code-line-ctn,
.dark-mode .d2h-code-side-linenumber {
background-color: #333 !important;
color: #ddd !important;
border-color: #444 !important;
}
/* 11) Modal w trybie ciemnym */
.dark-mode .modal-content {
background-color: #333;
color: #ddd;
border: none;
}
.dark-mode .modal-header,
.dark-mode .modal-footer {
border-color: #444;
}
.dark-mode .modal-title {
color: #fff;
}
.dark-mode .btn-close {
filter: invert(1);
}
/* 12) Niestandardowy styl trybu jasnego navbar */
.navbar-light.bg-custom-light {
background-color: #dcdcdc !important;
}
/* 13) DataTables w trybie ciemnym */
.dark-mode .dataTables_wrapper .dataTables_paginate .paginate_button {
background-color: #333 !important;
color: #fff !important;
border: 1px solid #555 !important;
}
.dark-mode .dataTables_wrapper .dataTables_paginate .paginate_button.current,
.dark-mode .dataTables_wrapper .dataTables_paginate .paginate_button:hover {
background-color: #444 !important;
color: #fff !important;
}
html.dark-mode .dataTables_wrapper .pagination .page-link {
background-color: #333 !important;
color: #fff !important;
border: 1px solid #555 !important;
}
html.dark-mode .dataTables_wrapper .pagination .page-item.active .page-link,
html.dark-mode .dataTables_wrapper .pagination .page-link:hover {
background-color: #444 !important;
color: #fff !important;
}
html.dark-mode .dataTables_wrapper .dataTables_info,
html.dark-mode .dataTables_wrapper .dataTables_length,
html.dark-mode .dataTables_wrapper .dataTables_filter {
color: #fff !important;
}
.dark-mode input:focus,
.dark-mode textarea:focus,
.dark-mode select:focus {
background-color: #333 !important;
color: #fff !important;
border-color: #555 !important;
box-shadow: none !important;
}
/* 14) Drobne poprawki przycisków wylogowania */
.btn-logout {
color: #fff;
}
/* 15) Główne ustawienia, flex layout */
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
main.container {
flex: 1;
}
/* 16) Alerty (diff-add, diff-rem) pozostawione bez zmian */
.diff-add { color: green; }
.diff-rem { color: red; }
.dark-mode .text-muted {
color: #aaa !important; /* zamiast #aaa możesz wybrać #bbb, #ccc itp. */
}
</style>
{% block head %}{% endblock %}
</head>
<body class="d-flex flex-column">
<!-- Navbar -->
<nav class="navbar navbar-expand-lg
{% if session.get('dark_mode', True) %}navbar-dark bg-dark{% else %}navbar-light bg-custom-light{% endif %}
mb-4">
<body>
<nav class="navbar navbar-expand navbar-dark bg-dark mb-4">
<div class="container-fluid">
<a href="/" class="navbar-brand">Backup RouterOS</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown"
aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
{% if session.get('user_id') %}
<ul class="navbar-nav me-auto">
<!-- Dashboard -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('dashboard') }}">Dashboard</a>
</li>
<!-- Urządzenia dropdown -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="devicesDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Urządzenia
</a>
<ul class="dropdown-menu" aria-labelledby="devicesDropdown">
<li><a class="dropdown-item" href="/routers">Lista</a></li>
<li><a class="dropdown-item" href="/routers/add">Dodaj nowe</a></li>
</ul>
</li>
<!-- Diff -->
<li class="nav-item">
<a class="nav-link" href="/diff_selector">Diff</a>
</li>
<!-- Wszystkie pliki -->
<li class="nav-item">
<a class="nav-link" href="/all_files">Wszystkie pliki</a>
</li>
<!-- Logi -->
<li class="nav-item">
<a class="nav-link" href="/logs">Logi</a>
</li>
<!-- Ustawienia dropdown -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="settingsDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Ustawienia
</a>
<ul class="dropdown-menu" aria-labelledby="settingsDropdown">
<li><a class="dropdown-item" href="/settings">Główne</a></li>
<li><a class="dropdown-item" href="/advanced_schedule">Harmonogram</a></li>
</ul>
</li>
</ul>
{% endif %}
<!-- Prawa strona navbaru -->
<ul class="navbar-nav ms-auto align-items-center">
<!-- Przełącznik trybu ciemnego -->
<li class="nav-item me-2">
<form action="{{ url_for('toggle_dark_mode') }}" method="GET" class="d-flex align-items-center">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="darkModeSwitch"
onchange="this.form.submit()"
{% if session.get('dark_mode', True) %}checked{% endif %}>
<label class="form-check-label ms-1" for="darkModeSwitch" style="user-select:none;">Ciemny</label>
</div>
</form>
</li>
{% if session.get('user_id') %}
<li class="nav-item">
<a class="nav-link btn btn-alert ms-2 btn-logout" href="{{ url_for('change_password') }}">Zmień hasło</a>
</li>
<li class="nav-item">
<a class="nav-link btn btn-danger ms-2 btn-logout" href="{{ url_for('logout') }}">Wyloguj</a>
</li>
<a href="{{ url_for('index') }}" class="navbar-brand">Backup RouterOS</a>
<div>
{% if session.user_id %}
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary me-2">Dashboard</a>
<a href="{{ url_for('routers_list') }}" class="btn btn-secondary me-2">Urządzenia</a>
<a href="{{ url_for('diff_selector') }}" class="btn btn-secondary me-2">Diff selector</a>
<a href="{{ url_for('all_files') }}" class="btn btn-secondary me-2">Wszystkie pliki</a>
<a href="{{ url_for('settings_view') }}" class="btn btn-secondary me-2">Ustawienia</a>
<a href="{{ url_for('advanced_schedule') }}" class="btn btn-secondary me-2">Harmonogram</a>
<a href="{{ url_for('change_password') }}" class="btn btn-secondary me-2">Zmiana hasła</a>
<a href="{{ url_for('logout') }}" class="btn btn-secondary me-2">Wyloguj</a>
{% else %}
<li class="nav-item">
<a class="nav-link btn btn-success ms-2" href="{{ url_for('login') }}">Zaloguj się</a>
</li>
<li class="nav-item">
<a class="nav-link btn btn-primary ms-2" href="{{ url_for('register') }}">Zarejestruj się</a>
</li>
<a href="{{ url_for('login') }}" class="btn btn-secondary me-2">Zaloguj</a>
<a href="{{ url_for('register') }}" class="btn btn-secondary me-2">Utwórz konto</a>
{% endif %}
</ul>
<!--<a href="{{ url_for('toggle_dark_mode') }}" class="btn btn-warning">Toggle Dark Mode</a>-->
</div>
</div>
</nav>
<!-- Główna zawartość -->
<main class="container mb-5">
{% with messages = get_flashed_messages(with_categories=true) %}
<div class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for category, msg in messages %}
{% set bs_cat = bootstrap_alert_category(category) %}
<div class="alert alert-{{ bs_cat }} alert-dismissible fade show" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div class="alert alert-info">
{% for msg in messages %}
<div>{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
<!-- Modal Test Połączenia -->
<div class="modal fade" id="testConnectionModal" tabindex="-1" aria-labelledby="testConnectionModalLabel" aria-hidden="true">
@@ -353,25 +68,36 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body" id="testConnectionModalBody">
<!-- Zawartość ładowana przez AJAX -->
<!-- Zawartość zostanie załadowana przez AJAX -->
</div>
</div>
</div>
</div>
<!-- Stopka -->
<footer class="footer py-3 mt-auto">
<div class="container text-center">
<span>&copy; 2025 Mateusz Gruszczyński, linuxiarz.pl</span>
</div>
</footer>
<!-- Bootstrap Bundle JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Dodatkowe skrypty -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function ajaxExport(router_id) {
fetch("/router/" + router_id + "/export", {
method: "POST",
headers: {"X-Requested-With": "XMLHttpRequest"}
})
.then(response => response.json())
.then(data => {
if(data.status === "success"){
alert("Eksport wykonany: " + data.message);
// Możesz też zaktualizować część strony dynamicznie
} else {
alert("Błąd eksportu: " + data.message);
}
})
.catch(error => {
console.error("Błąd AJAX:", error);
alert("Wystąpił błąd.");
});
}
</script>
<script>
// Funkcja do wczytywania modalu testu połączenia
function openTestConnectionModal(routerId) {
fetch('/router/' + routerId + '/test_connection?modal=1')
.then(response => response.text())
@@ -386,7 +112,5 @@
});
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-5">
<div class="row justify-content-center">
<div class="container">
<div class="row justify-content-center align-items-center" style="min-height: 100vh;">
<div class="col-md-6">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light text-center">
<h4 class="mb-0">Zmień hasło</h4>
<div class="card shadow">
<div class="card-header text-center">
<h2>Zmień hasło</h2>
</div>
<div class="card-body">
<form method="POST">

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<h2 class="text-center mb-4">Dashboard</h2>
<h2 class="text-center mb-4">Panel administracyjny</h2>
<!-- Wiersz akcji ogólnych -->
<div class="row mb-4">
<div class="col text-center">
<div class="col-md-12 text-center">
<a href="{{ url_for('routers_list') }}" class="btn btn-lg btn-outline-primary">
<i class="bi bi-hdd-network"></i> Zobacz routery
</a>
@@ -53,14 +53,14 @@
<div class="col-md-6 d-flex justify-content-center">
<form action="{{ url_for('export_all_routers') }}" method="POST">
<button type="submit" class="btn btn-lg btn-outline-success">
<i class="bi bi-arrow-down-circle"></i> Eksport wszystkich routerów
<i class="bi bi-arrow-down-circle"></i> Eksport dla wszystkich routerów
</button>
</form>
</div>
<div class="col-md-6 d-flex justify-content-center">
<form action="{{ url_for('backup_all_routers') }}" method="POST">
<button type="submit" class="btn btn-lg btn-outline-secondary">
<i class="bi bi-cloud-download"></i> Backup binarny wszystkich routerów
<i class="bi bi-cloud-download"></i> Backup binarny dla wszystkich routerów
</button>
</form>
</div>
@@ -73,11 +73,11 @@
{% else %}
{% set success_percent = 0 %}
{% endif %}
<div class="card mb-4 shadow-sm border-0">
<div class="card mb-4 shadow-sm">
<div class="card-body">
<h5 class="card-title">Statystyki operacji</h5>
<p>Udane operacje: {{ success_ops }}, Nieudane operacje: {{ failure_ops }}</p>
<div class="progress mb-2">
<div class="progress">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ success_percent }}%;" aria-valuenow="{{ success_percent }}" aria-valuemin="0" aria-valuemax="100">
{{ success_percent }}%
</div>
@@ -89,12 +89,9 @@
</div>
<!-- Log operacji -->
<div class="card shadow-sm border-0 mb-4">
<div class="card shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title">
Log operacji
<a href="{{ url_for('logs_page') }}" class="btn btn-sm btn-outline-primary ms-2">Więcej logów</a>
</h5>
<h5 class="card-title">Log operacji</h5>
<table class="table table-sm table-bordered">
<thead>
<tr>
@@ -114,8 +111,8 @@
</div>
</div>
<!-- Dodatkowe statystyki -->
<div class="card shadow-sm border-0">
<!-- Dodatkowe statystyki przeniesione na sam dół, pod logami -->
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">Dodatkowe statystyki</h5>
<div class="row">
@@ -131,5 +128,6 @@
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,25 +1,19 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light">
<h4 class="mb-0">
Porównanie: {{ backup1.file_path|basename }} vs {{ backup2.file_path|basename }}
</h4>
</div>
<div class="card-body">
<h2>Porównanie: {{ backup1.file_path|basename }} vs {{ backup2.file_path|basename }}</h2>
<hr>
<div id="diffContainer"></div>
<a href="{{ url_for('router_details', router_id=backup1.router_id) }}" class="btn btn-secondary mt-3">Powrót</a>
</div>
</div>
</div>
<!-- diff2html resources -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html@3.4.4/bundles/css/diff2html.min.css" />
<script src="https://cdn.jsdelivr.net/npm/diff2html@3.4.4/bundles/js/diff2html.min.js"></script>
<!-- Dodajemy diff2html -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css" />
<script src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Upewnij się, że diff_text jest poprawnie escapowany
var diffText = `{{ diff_text|e }}`;
var targetElement = document.getElementById("diffContainer");
var configuration = {
@@ -29,19 +23,6 @@
};
var diffHtml = Diff2Html.html(diffText, configuration);
targetElement.innerHTML = diffHtml;
// Dark mode styl dla diff2html, jeśli potrzebne
if(document.body.classList.contains('dark-mode')) {
var darkStyle = document.createElement('style');
darkStyle.textContent = `
.d2h-wrapper { background-color: #1e1e1e; color: #fff; }
.d2h-file-header { background-color: #2e2e2e; color: #fff; }
.d2h-diff-table { background-color: #1e1e1e; color: #fff; }
.d2h-code-line { background-color: #1e1e1e; color: #fff; }
.d2h-code-line-ctn { color: #fff; }
`;
document.head.appendChild(darkStyle);
}
});
</script>
{% endblock %}

View File

@@ -1,10 +1,8 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="card shadow-sm border-0">
<div class="card-header bg-light d-flex align-items-center">
<h4 class="mb-0">Porównanie backupów (Diff)</h4>
</div>
<h2 class="text-center mb-4">Porównanie backupów (Diff)</h2>
<div class="card shadow-sm">
<div class="card-body">
<form action="{{ url_for('diff_selector') }}" method="POST" id="diffForm">
<div class="row mb-3">
@@ -31,10 +29,8 @@
</select>
</div>
</div>
<div class="text-center mt-4">
<button type="submit" class="btn btn-primary btn-lg">
Porównaj backupy
</button>
<div class="text-center">
<button type="submit" class="btn btn-primary btn-lg">Porównaj backupy</button>
</div>
</form>
</div>

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light">
<h4 class="mb-0">Edycja urządzenia</h4>
<div class="container mt-5">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">Edycja urządzenia</h2>
</div>
<div class="card-body">
<form method="POST">
@@ -25,15 +25,15 @@
</div>
<div class="mb-3">
<label for="ssh_key" class="form-label">
<b>Klucz prywatny</b>
</label><br>
<small>Wklej wraz z <code>-----BEGIN RSA PRIVATE KEY-----</code> i <code>-----END RSA PRIVATE KEY-----</code>. Jeśli pusty użyje klucza globalnego.</small>
<textarea class="form-control mt-2" id="ssh_key" name="ssh_key" rows="4">{{ router.ssh_key }}</textarea>
<label for="ssh_password" class="form-label"><b>Klucz prywatny</b></label> | Wklej wraz z <code>-----BEGIN RSA PRIVATE KEY-----</code> i <code>-----END RSA PRIVATE KEY-----</code><br>
Pozostaw puste jeśli ten RouterOS będzie używał <a href="{{ url_for('settings_view') }}">klucza globalnego</a>
</label>
<textarea class="form-control" id="ssh_key" name="ssh_key" rows="4">{{ router.ssh_key }}</textarea>
</div>
<div class="mb-3">
<label for="ssh_password" class="form-label"><b>Hasło SSH</b></label><br>
<small>Jeśli jest klucz SSH lub klucz globalny, hasło może być ignorowane.</small>
<input type="password" class="form-control mt-2" id="ssh_password" name="ssh_password" value="{{ router.ssh_password }}">
Jeśli podajesz klucz SSH lub zdefiniowany jest <a href="{{ url_for('settings_view') }}">klucz globalny</a>, to logowanie hasłem jest nieaktywne.
<input type="password" class="form-control" id="ssh_password" name="ssh_password" value="{{ router.ssh_password }}">
</div>
<button type="submit" class="btn btn-success">Zapisz zmiany</button>
</form>

View File

@@ -2,11 +2,7 @@
{% block content %}
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height: 80vh;">
<div class="text-center">
{% if session.get('dark_mode', True) %}
<img src="https://mikrotik.com/logo/assets/logo-colors-white-E8duxH7y.svg" alt="Mikrotik Logo" class="img-fluid" style="max-width: 200px;">
{% else %}
<img src="https://mikrotik.com/logo/assets/logo-colors-dark-ToiqSI6u.svg" alt="Mikrotik Logo" class="img-fluid" style="max-width: 200px;">
{% endif %}
<h1 class="mt-3">Witamy w aplikacji Backup RouterOS</h1>
<p class="lead">Zarządzaj backupami swoich urządzeń RouterOS w prosty sposób.</p>
<div class="mt-4">
@@ -16,4 +12,3 @@
</div>
</div>
{% endblock %}

View File

@@ -1,23 +1,21 @@
{% extends "base.html" %}
{% block head %}{% endblock %}
{% block content %}
<div class="container my-5">
<div class="row justify-content-center align-items-center">
<div class="container">
<div class="row justify-content-center align-items-center" style="min-height: 100vh;">
<div class="col-md-6">
<div class="card shadow border-0">
<div class="card-header bg-light text-center">
<h4 class="mb-0">Zaloguj się</h4>
<div class="card shadow">
<div class="card-header text-center">
<h2>Zaloguj się</h2>
</div>
<div class="card-body">
<form action="{{ url_for('login') }}" method="POST">
<div class="mb-3">
<label for="username" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="username" name="username" placeholder="Wpisz nazwę użytkownika" required>
<input type="text" class="form-control" id="username" name="username" placeholder="Wpisz nazwę użytkownika">
</div>
<div class="mb-3">
<label for="password" class="form-label">Hasło</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Wpisz hasło" required>
<input type="password" class="form-control" id="password" name="password" placeholder="Wpisz hasło">
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Zaloguj się</button>
@@ -25,10 +23,7 @@
</form>
</div>
<div class="card-footer text-center">
<small>
Nie masz konta?
<a href="{{ url_for('register') }}">Zarejestruj się</a>
</small>
<a href="{{ url_for('register') }}">Nie masz konta? Zarejestruj się</a>
</div>
</div>
</div>

View File

@@ -1,76 +0,0 @@
{% extends "base.html" %}
{% block head %}
{{ super() }}
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/dataTables.bootstrap5.min.css">
{% endblock %}
{% block content %}
<div class="container my-4">
<h2 class="text-center mb-4">Historia logów operacji</h2>
<!-- Karta z usuwaniem logów -->
<div class="card mb-4 shadow-sm border-0">
<div class="card-body">
<form action="{{ url_for('delete_old_logs') }}" method="POST" class="row g-2 align-items-center">
<div class="col-auto">
<label for="delete_days" class="col-form-label">Usuń logi starsze niż (dni):</label>
</div>
<div class="col-auto">
<input type="number" class="form-control" id="delete_days" name="delete_days" min="1" placeholder="Liczba dni" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-danger">Usuń logi</button>
</div>
</form>
</div>
</div>
<!-- Tabela logów -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<table id="logsTable" class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th>Data</th>
<th>Wiadomość</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td>{{ log.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="text-center mt-3">
<a href="{{ url_for('dashboard') }}" class="btn btn-outline-primary">Powrót do Dashboardu</a>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- DataTables JS -->
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.4/js/dataTables.bootstrap5.min.js"></script>
<script>
$(document).ready(function() {
$('#logsTable').DataTable({
responsive: true,
order: [[0, 'desc']],
language: {
url: '//cdn.datatables.net/plug-ins/1.13.4/i18n/pl.json'
}
});
});
</script>
{% endblock %}

View File

@@ -1,23 +1,21 @@
{% extends "base.html" %}
{% block head %}{% endblock %}
{% block content %}
<div class="container my-5">
<div class="row justify-content-center align-items-center">
<div class="container">
<div class="row justify-content-center align-items-center" style="min-height: 100vh;">
<div class="col-md-6">
<div class="card shadow border-0">
<div class="card-header bg-light text-center">
<h4 class="mb-0">Rejestracja</h4>
<div class="card shadow">
<div class="card-header text-center">
<h2>Rejestracja</h2>
</div>
<div class="card-body">
<form action="{{ url_for('register') }}" method="POST">
<div class="mb-3">
<label for="username" class="form-label">Nazwa użytkownika</label>
<input type="text" class="form-control" id="username" name="username" placeholder="Wpisz nazwę użytkownika" required>
<input type="text" class="form-control" id="username" name="username" placeholder="Wpisz nazwę użytkownika">
</div>
<div class="mb-3">
<label for="password" class="form-label">Hasło</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Wpisz hasło" required>
<input type="password" class="form-control" id="password" name="password" placeholder="Wpisz hasło">
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Zarejestruj się</button>
@@ -25,10 +23,7 @@
</form>
</div>
<div class="card-footer text-center">
<small>
Masz już konto?
<a href="{{ url_for('login') }}">Zaloguj się</a>
</small>
<a href="{{ url_for('login') }}">Masz już konto? Zaloguj się</a>
</div>
</div>
</div>

View File

@@ -28,7 +28,6 @@
<h3>Pliki z /export</h3>
{% if export_backups %}
<!-- Tabela z indywidualnymi akcjami -->
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
@@ -55,6 +54,7 @@
<td>
<a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-sm btn-info">Pobierz</a>
<a href="{{ url_for('view_export', backup_id=b.id) }}" class="btn btn-sm btn-outline-primary">Podgląd</a>
<form action="{{ url_for('send_by_email', backup_id=b.id) }}" method="POST" style="display: inline;">
<button type="submit" class="btn btn-sm btn-primary">Wyślij mailem</button>
</form>
@@ -67,11 +67,8 @@
{% endfor %}
</tbody>
</table>
</div>
<!-- Formularz do pobierania ZIP zaznaczonych eksportów -->
<h4>Pobierz wybrane pliki z /export jako zip</h4>
<div class="table-responsive">
<form action="{{ url_for('download_zip') }}" method="POST">
<table class="table table-bordered">
<thead>
@@ -95,7 +92,6 @@
</table>
<button type="submit" class="btn btn-success">Pobierz zaznaczone (.zip)</button>
</form>
</div>
{% else %}
<p class="text-muted">Pusto</p>
{% endif %}
@@ -105,7 +101,6 @@
<!-- Sekcja backupów binarnych -->
<h3>Pliki binarne (.backup)</h3>
{% if binary_backups %}
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
@@ -123,6 +118,7 @@
<td>{{ b.created_at }}</td>
<td>
<a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-sm btn-info">Pobierz</a>
<form action="{{ url_for('upload_backup', router_id=router.id, backup_id=b.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-secondary">Wgraj do routera</button>
</form>
@@ -138,11 +134,9 @@
{% endfor %}
</tbody>
</table>
</div>
<!-- Formularz do pobierania ZIP zaznaczonych backupów binarnych -->
<h4>Pobierz wybrane backupy binarne jako zip</h4>
<div class="table-responsive">
<form action="{{ url_for('download_zip') }}" method="POST">
<table class="table table-bordered">
<thead>
@@ -166,7 +160,6 @@
</table>
<button type="submit" class="btn btn-success">Pobierz zaznaczone (.zip)</button>
</form>
</div>
{% else %}
<p class="text-muted">Pusto</p>
{% endif %}

View File

@@ -1,17 +1,17 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="mb-3 text-end">
<div class="text-end mb-3">
{% if current_view == 'v1' %}
<a href="{{ url_for('router_details', router_id=router.id, view='v2') }}" class="btn btn-outline-secondary">Przełącz na widok v2</a>
{% else %}
<a href="{{ url_for('router_details', router_id=router.id, view='v1') }}" class="btn btn-outline-secondary">Przełącz na widok v1</a>
{% endif %}
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-light">
<h4 class="mb-0">Router: {{ router.name }}</h4>
<div class="container my-4">
<!-- Informacje o routerze -->
<div class="card shadow-sm mb-4">
<div class="card-header">
<h2 class="mb-0">Router: {{ router.name }}</h2>
</div>
<div class="card-body">
<p>
@@ -20,13 +20,14 @@
<strong>SSH User:</strong> {{ router.ssh_user }}
</p>
<div class="d-flex flex-wrap gap-2">
<!-- Mniejsze przyciski górne -->
<form action="{{ url_for('router_export', router_id=router.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm">Wykonaj /export</button>
<button type="submit" class="btn btn-primary">Wykonaj /export</button>
</form>
<form action="{{ url_for('router_backup', router_id=router.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-secondary btn-sm">Wykonaj backup binarny</button>
<button type="submit" class="btn btn-secondary">Wykonaj backup binarny</button>
</form>
<a href="{{ url_for('edit_router', router_id=router.id) }}" class="btn btn-warning btn-sm">Edytuj ustawienia</a>
<a href="{{ url_for('edit_router', router_id=router.id) }}" class="btn btn-warning">Edytuj ustawienia</a>
</div>
</div>
</div>
@@ -34,34 +35,28 @@
<!-- Zakładki z backupami -->
<ul class="nav nav-tabs" id="routerTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="export-tab" data-bs-toggle="tab" data-bs-target="#export" type="button" role="tab" aria-controls="export" aria-selected="true">
Pliki /export
</button>
<button class="nav-link active" id="export-tab" data-bs-toggle="tab" data-bs-target="#export" type="button" role="tab" aria-controls="export" aria-selected="true">Pliki /export</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="binary-tab" data-bs-toggle="tab" data-bs-target="#binary" type="button" role="tab" aria-controls="binary" aria-selected="false">
Pliki binarne
</button>
<button class="nav-link" id="binary-tab" data-bs-toggle="tab" data-bs-target="#binary" type="button" role="tab" aria-controls="binary" aria-selected="false">Pliki binarne</button>
</li>
</ul>
<div class="tab-content" id="routerTabContent">
<!-- Zakładka /export -->
<div class="tab-pane fade show active" id="export" role="tabpanel" aria-labelledby="export-tab">
<div class="card mt-3 shadow-sm border-0">
<div class="card mt-3 shadow-sm">
<div class="card-body">
{% if export_backups %}
<!-- Formularz masowych akcji dla eksportów -->
<form id="export_mass_actions_form" action="{{ url_for('download_zip') }}" method="POST" class="mb-3">
<div class="d-flex justify-content-end">
<button type="submit" name="action" value="download" class="btn btn-success btn-sm">
<button type="submit" name="action" value="download" class="btn btn-lg btn-success">
<i class="bi bi-file-earmark-zip"></i> Pobierz zaznaczone (.zip)
</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-bordered table-striped align-middle">
<!-- Tabela z eksportami z podzielonymi kolumnami akcji -->
<table class="table table-bordered table-striped">
<thead class="table-dark">
<tr>
<th style="width: 3%;"><input type="checkbox" id="select_all_export"></th>
@@ -92,19 +87,19 @@
{% endif %}
</td>
<td>
<a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-info btn-sm" title="Pobierz">
<a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-lg btn-info" title="Pobierz">
<i class="bi bi-download"></i>
</a>
</td>
<td>
<a href="{{ url_for('view_export', backup_id=b.id) }}" class="btn btn-outline-primary btn-sm" title="Podgląd">
<a href="{{ url_for('view_export', backup_id=b.id) }}" class="btn btn-lg btn-outline-primary" title="Podgląd">
<i class="bi bi-eye"></i>
</a>
</td>
<td>
<form action="{{ url_for('send_by_email', backup_id=b.id) }}" method="POST" class="d-inline">
<input type="hidden" name="next" value="{{ url_for('router_details', router_id=router.id) }}">
<button type="submit" class="btn btn-primary btn-sm" title="Wyślij mailem">
<button type="submit" class="btn btn-lg btn-primary" title="Wyślij mailem">
<i class="bi bi-envelope"></i>
</button>
</form>
@@ -112,7 +107,7 @@
<td>
<form action="{{ url_for('delete_backup', backup_id=b.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć backup?');">
<input type="hidden" name="next" value="{{ url_for('router_details', router_id=router.id) }}">
<button type="submit" class="btn btn-danger btn-sm" title="Usuń">
<button type="submit" class="btn btn-lg btn-danger" title="Usuń">
<i class="bi bi-trash"></i>
</button>
</form>
@@ -121,30 +116,27 @@
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Brak plików /export.</p>
{% endif %}
</div>
</div>
</div>
<!-- Zakładka backupów binarnych -->
<div class="tab-pane fade" id="binary" role="tabpanel" aria-labelledby="binary-tab">
<div class="card mt-3 shadow-sm border-0">
<div class="card mt-3 shadow-sm">
<div class="card-body">
{% if binary_backups %}
<!-- Formularz masowych akcji dla backupów binarnych -->
<form id="binary_mass_actions_form" action="{{ url_for('download_zip') }}" method="POST" class="mb-3">
<div class="d-flex justify-content-end">
<button type="submit" name="action" value="download" class="btn btn-success btn-sm">
<button type="submit" name="action" value="download" class="btn btn-lg btn-success">
<i class="bi bi-file-earmark-zip"></i> Pobierz zaznaczone (.zip)
</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-bordered table-striped align-middle">
<!-- Tabela z backupami binarnymi z podzielonymi kolumnami akcji -->
<table class="table table-bordered table-striped">
<thead class="table-dark">
<tr>
<th style="width: 3%;"><input type="checkbox" id="select_all_binary"></th>
@@ -163,26 +155,24 @@
<td>
<input type="checkbox" name="backup_id" value="{{ b.id }}" form="binary_mass_actions_form">
</td>
<td>
<span data-bs-toggle="tooltip" title="Checksum: {{ b.checksum }}">{{ b.file_path|basename }}</span>
</td>
<td>{{ b.file_path|basename }}</td>
<td>{{ b.file_path|filesize }}</td>
<td>{{ b.created_at.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td>
<a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-info btn-sm" title="Pobierz">
<a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-lg btn-info" title="Pobierz">
<i class="bi bi-download"></i>
</a>
</td>
<td>
<form action="{{ url_for('upload_backup', router_id=router.id, backup_id=b.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-secondary btn-sm" title="Wgraj do routera">
<button type="submit" class="btn btn-lg btn-secondary" title="Wgraj do routera">
<i class="bi bi-upload"></i>
</button>
</form>
</td>
<td>
<form action="{{ url_for('send_by_email', backup_id=b.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm" title="Wyślij mailem">
<button type="submit" class="btn btn-lg btn-primary" title="Wyślij mailem">
<i class="bi bi-envelope"></i>
</button>
</form>
@@ -190,7 +180,7 @@
<td>
<form action="{{ url_for('delete_backup', backup_id=b.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć backup?');">
<input type="hidden" name="next" value="{{ url_for('router_details', router_id=router.id) }}">
<button type="submit" class="btn btn-danger btn-sm" title="Usuń">
<button type="submit" class="btn btn-lg btn-danger" title="Usuń">
<i class="bi bi-trash"></i>
</button>
</form>
@@ -199,7 +189,6 @@
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Brak plików binarnych.</p>
{% endif %}
@@ -213,14 +202,18 @@
<script>
document.getElementById('select_all_export').addEventListener('change', function(e) {
var checkboxes = document.querySelectorAll('input[name="backup_id"][form="export_mass_actions_form"]');
checkboxes.forEach(cb => cb.checked = e.target.checked);
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = e.target.checked;
}
});
document.getElementById('select_all_binary').addEventListener('change', function(e) {
var checkboxes = document.querySelectorAll('input[name="backup_id"][form="binary_mass_actions_form"]');
checkboxes.forEach(cb => cb.checked = e.target.checked);
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = e.target.checked;
}
});
// Inicjalizacja zakładek Bootstrap
// Inicjalizacja zakładek Bootstrap (jeśli nie są już inicjowane globalnie)
var triggerTabList = [].slice.call(document.querySelectorAll('#routerTab button'));
triggerTabList.forEach(function (triggerEl) {
var tabTrigger = new bootstrap.Tab(triggerEl);
@@ -229,11 +222,5 @@ triggerTabList.forEach(function (triggerEl) {
tabTrigger.show();
});
});
// Inicjalizacja tooltipów
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
});
</script>
{% endblock %}

View File

@@ -1,16 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h4 class="mb-0">Lista urządzeń</h4>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Moje Routery</h2>
<a href="{{ url_for('add_router') }}" class="btn btn-success">
<i class="bi bi-plus-lg"></i> Dodaj nowe urządzenie
</a>
</div>
<div class="card-body">
<div class="table-responsive-sm">
<table class="table table-striped table-hover align-middle">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-primary">
<tr>
<th>Nazwa</th>
@@ -50,8 +48,8 @@
<a href="{{ url_for('edit_router', router_id=router.id) }}" class="btn btn-sm btn-warning">
<i class="bi bi-pencil"></i> Edytuj
</a>
<form action="{{ url_for('delete_router', router_id=router.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć urządzenie?');">
<button type="submit" class="btn btn-sm btn-danger">
<form action="{{ url_for('delete_router', router_id=router.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Na pewno usunąć urządzenie?');">
<i class="bi bi-trash"></i> Usuń
</button>
</form>
@@ -62,6 +60,4 @@
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,16 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light">
<h4 class="mb-0">Ustawienia globalne</h4>
<div class="container my-5">
<div class="card shadow-sm">
<div class="card-header">
<h2 class="mb-0">Ustawienia globalne</h2>
</div>
<div class="card-body">
<form method="POST">
<!-- Sekcja Pushover -->
<div class="mb-4">
<h5 class="mb-3">Powiadomienia - Pushover</h5>
<h4 class="mb-3">Powiadomienia - Pushover</h4>
<div class="mb-3">
<label for="pushover_token" class="form-label">Pushover Token</label>
<input type="text" class="form-control" id="pushover_token" name="pushover_token" value="{{ settings.pushover_token }}">
@@ -25,11 +24,10 @@
</div>
</div>
<hr>
<!-- Sekcja SMTP -->
<div class="mb-4">
<h5 class="mb-3">Powiadomienia - SMTP (e-mail)</h5>
<div class="form-check mb-3">
<h4 class="mb-3">Powiadomienia - SMTP (e-mail)</h4>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="smtp_notifications_enabled" name="smtp_notifications_enabled" {% if settings.smtp_notifications_enabled %}checked{% endif %}>
<label class="form-check-label" for="smtp_notifications_enabled">Włącz powiadomienia SMTP</label>
</div>
@@ -49,43 +47,25 @@
<label for="smtp_password" class="form-label">SMTP Hasło</label>
<input type="password" class="form-control" id="smtp_password" name="smtp_password" value="{{ settings.smtp_password }}">
</div>
<div class="mb-3">
<label for="recipient_email" class="form-label">Adres e-mail docelowy</label>
<input type="email" class="form-control" id="recipient_email" name="recipient_email" value="{{ settings.recipient_email }}">
</div>
</div>
<hr>
<!-- Sekcja globalnego klucza SSH -->
<div class="mb-4">
<h5 class="mb-3">Globalny klucz SSH</h5>
<h4 class="mb-3">Globalny klucz SSH</h4>
<div class="mb-3">
<label for="global_ssh_key" class="form-label">
Wklej wraz z <code>-----BEGIN RSA PRIVATE KEY-----</code> i <code>-----END RSA PRIVATE KEY-----</code>
</label>
<textarea class="form-control" id="global_ssh_key" name="global_ssh_key" rows="4">{{ settings.global_ssh_key }}</textarea>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">Zapisz ustawienia</button>
</div>
</form>
<!-- Przycisk do testowania powiadomień -->
<div class="mt-4 text-center">
<form method="POST" action="{{ url_for('test_email') }}" class="d-inline">
<button type="submit" class="btn btn-info">Testuj wysyłkę e-mail</button>
</form>
<form method="POST" action="{{ url_for('test_pushover') }}" class="d-inline ms-2">
<button type="submit" class="btn btn-warning">Testuj powiadomienie Pushover</button>
</form>
</div>
</div>
<div class="card-footer text-center">
<p class="mb-0">
Ustawienia harmonogramu i retencji:
<a href="{{ url_for('advanced_schedule') }}">Zaawansowane ustawienia</a>
</p>
<p>Ustawienia dotyczące backupu oraz harmonogramu CRON znajdują się na <a href="{{ url_for('advanced_schedule') }}">zaawansowanych ustawieniach harmonogramu</a>.</p>
</div>
</div>
</div>

View File

@@ -1,42 +1,30 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-4">
<div class="card border-0 shadow">
<div class="card-header bg-light">
<h4 class="mb-0">
Podgląd eksportu: {{ backup.file_path|basename }}
</h4>
</div>
<div class="card-body">
<h2>Podgląd eksportu: {{ backup.file_path|basename }}</h2>
<hr>
<textarea id="exportEditor" readonly>{{ content|e }}</textarea>
<div class="mt-3">
<a href="{{ next_url }}" class="btn btn-secondary">Powrót</a>
</div>
</div>
</div>
</div>
<!-- CodeMirror CSS -->
{% if session.get('dark_mode', True) %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/darcula.min.css">
{% else %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/neo.min.css">
{% endif %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/neo.min.css">
<!-- CodeMirror JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"></script>
<!-- Dodajemy tryb dla plików shell, który dobrze radzi sobie z konfiguracjami RouterOS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/shell/shell.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
var editor = CodeMirror.fromTextArea(document.getElementById("exportEditor"), {
mode: "text/x-sh",
theme: "{{ 'darcula' if session.get('dark_mode', True) else 'neo' }}",
theme: "neo",
lineNumbers: true,
readOnly: true
});
editor.setSize("100%", "600px");
// Dopasowanie rozmiaru edytora do zawartości
editor.setSize("100%", "800px");
});
</script>
{% endblock %}