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 # Register, Login
```sh
git clone https://gitea.linuxiarz.pl/gru/routeros_backup.git
cd routeros_backup
```
### 2. Tworzenie i aktywacja środowiska wirtualnego (opcjonalnie) # Configure devices, keys, backups, crons
```sh
python3 -m venv venv
source venv/bin/activate # Linux/macOS
venv\Scripts\activate # Windows
```
### 3. Instalacja zależności
```sh
pip install -r requirements.txt
```
### 4. Uruchomienie aplikacji lokalnie ## Authors
```sh
python run_waitress.py
```
Aplikacja będzie dostępna pod adresem: `http://127.0.0.1:5581/`
--- - [@linuxiarz.pl]
## 📦 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.

224
app.py
View File

@@ -9,16 +9,18 @@ import re
import smtplib import smtplib
import shutil import shutil
import socket import socket
import hashlib
from datetime import datetime from datetime import datetime
from ftplib import FTP
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email import encoders from email import encoders
from flask import jsonify from flask import jsonify
from flask import Flask from flask import Flask
import shutil
from datetime import datetime from datetime import datetime
from difflib import HtmlDiff from difflib import HtmlDiff
import difflib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy import text from sqlalchemy import text
@@ -27,7 +29,7 @@ from flask import (
url_for, session, flash, send_file url_for, session, flash, send_file
) )
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from passlib.context import CryptContext from passlib.hash import bcrypt
#from flask_wtf.csrf import CSRFProtect #from flask_wtf.csrf import CSRFProtect
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@@ -56,18 +58,14 @@ os.makedirs(DATA_DIR, exist_ok=True)
############################################################################### ###############################################################################
# Modele bazy danych # Modele bazy danych
############################################################################### ###############################################################################
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class User(db.Model): class User(db.Model):
__tablename__ = 'users' __tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(120), unique=True, nullable=False) username = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), 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): def check_password(self, password):
return pwd_context.verify(password, self.password_hash) return bcrypt.verify(password, self.password_hash)
class Router(db.Model): class Router(db.Model):
__tablename__ = 'routers' __tablename__ = 'routers'
@@ -89,8 +87,6 @@ class Backup(db.Model):
file_path = db.Column(db.String(255), nullable=False) # Ścieżka do pliku 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' backup_type = db.Column(db.String(50), default='export') # 'export' lub 'binary'
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
checksum = db.Column(db.String(64), nullable=True)
class OperationLog(db.Model): class OperationLog(db.Model):
__tablename__ = 'operation_logs' __tablename__ = 'operation_logs'
__table_args__ = {'extend_existing': True} # Zapobiega redefinicji tabeli __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_login = db.Column(db.String(255), nullable=True)
smtp_password = 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) 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 # Inicjalizacja bazy
@@ -172,13 +166,6 @@ def load_pkey(ssh_key_str: str):
except Exception as e: 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 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 # Funkcje SSH
############################################################################### ###############################################################################
@@ -242,17 +229,12 @@ def ssh_backup(router: Router, backup_name: str) -> str:
print(f"[DEBUG] ssh_backup -> local_path={local_path}") print(f"[DEBUG] ssh_backup -> local_path={local_path}")
return local_path return local_path
def ssh_upload_backup(router: Router, local_backup_path: str, expected_checksum: str = None): def ssh_upload_backup(router: Router, local_backup_path: str):
# Weryfikacja sumy kontrolnej, jeśli podana print(f"[DEBUG] ssh_upload_backup -> router id={router.id}, local_backup_path={local_backup_path}")
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.")
client = paramiko.SSHClient() client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 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 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(): if key_source and key_source.strip():
try: try:
@@ -263,10 +245,8 @@ def ssh_upload_backup(router: Router, local_backup_path: str, expected_checksum:
raise e raise e
else: else:
client.connect(router.host, port=router.port, username=router.ssh_user, client.connect(router.host, port=router.port, username=router.ssh_user,
password=router.ssh_password, timeout=10, password=router.ssh_password, timeout=10, allow_agent=False, look_for_keys=False, banner_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() sftp = client.open_sftp()
remote_file = os.path.basename(local_backup_path) remote_file = os.path.basename(local_backup_path)
sftp.put(local_backup_path, remote_file) 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() client.close()
print(f"[DEBUG] ssh_upload_backup -> przesłano {local_backup_path} do routera") print(f"[DEBUG] ssh_upload_backup -> przesłano {local_backup_path} do routera")
def ssh_test_connection(router: Router) -> dict: def ssh_test_connection(router: Router) -> dict:
"""Testuje połączenie z routerem i zwraca informacje: model, uptime, hostname.""" """Testuje połączenie z routerem i zwraca informacje: model, uptime, hostname."""
client = paramiko.SSHClient() 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.") print("SMTP not properly configured, skipping email sending.")
return False return False
try: try:
# Utwórz wiadomość typu alternative (obsługuje plain text i HTML)
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["From"] = smtp_user msg["From"] = smtp_user
msg["To"] = to_address msg["To"] = to_address
msg["Subject"] = subject msg["Subject"] = subject
# Dodaj część tekstową
part1 = MIMEText(plain_body, "plain") part1 = MIMEText(plain_body, "plain")
msg.attach(part1) msg.attach(part1)
# Jeśli nie podano wersji HTML, wygeneruj domyślny szablon
if html_body is None: if html_body is None:
html_body = get_email_template(subject, plain_body) html_body = get_email_template(subject, plain_body)
part2 = MIMEText(html_body, "html") part2 = MIMEText(html_body, "html")
msg.attach(part2) msg.attach(part2)
# Dodaj załącznik, jeśli podany i istnieje
if attachment_path and os.path.isfile(attachment_path): if attachment_path and os.path.isfile(attachment_path):
with open(attachment_path, "rb") as attachment: with open(attachment_path, "rb") as attachment:
part = MIMEBase("application", "octet-stream") 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)}") part.add_header("Content-Disposition", f"attachment; filename={os.path.basename(attachment_path)}")
msg.attach(part) msg.attach(part)
if smtp_port == 465: with smtplib.SMTP(smtp_host, smtp_port) as server:
server = smtplib.SMTP_SSL(smtp_host, smtp_port) server.starttls()
server.login(smtp_user, smtp_pass) server.login(smtp_user, smtp_pass)
server.send_message(msg) server.send_message(msg)
server.quit()
else:
server = smtplib.SMTP(smtp_host, smtp_port)
server.ehlo()
if smtp_port == 587:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
server.quit()
return True return True
except Exception as e: except Exception as e:
print("Mail error_send_mail_with_attachment:", 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_login and settings.smtp_login.strip() and
settings.smtp_password and settings.smtp_password.strip()): settings.smtp_password and settings.smtp_password.strip()):
try: 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( send_mail_with_attachment(
smtp_host=settings.smtp_host.strip(), smtp_host=settings.smtp_host.strip(),
smtp_port=settings.smtp_port, smtp_port=settings.smtp_port,
smtp_user=settings.smtp_login.strip(), smtp_user=settings.smtp_login.strip(),
smtp_pass=settings.smtp_password.strip(), smtp_pass=settings.smtp_password.strip(),
to_address=to_address, to_address=settings.smtp_login.strip(),
subject="RouterOS Backup Notification", subject="RouterOS Backup Notification",
plain_body=message plain_body=message
) )
@@ -518,10 +491,10 @@ def scheduled_auto_backup():
db.session.add(b) db.session.add(b)
db.session.commit() db.session.commit()
notify(s, f"Auto-export dla routera {r.name} OK", True) 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: except Exception as e:
notify(s, f"Auto-export dla routera {r.name} FAILED: {e}", False) 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(): def scheduled_auto_binary_backup():
with app.app_context(): with app.app_context():
@@ -531,8 +504,7 @@ def scheduled_auto_binary_backup():
try: try:
backup_name = f"{r.name}_{r.id}_{datetime.now():%Y%m%d_%H%M%S}" backup_name = f"{r.name}_{r.id}_{datetime.now():%Y%m%d_%H%M%S}"
local_path = ssh_backup(r, backup_name) 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')
b = Backup(router_id=r.id, file_path=local_path, backup_type='binary', checksum=checksum)
db.session.add(b) db.session.add(b)
db.session.commit() db.session.commit()
notify(s, f"Auto-binary backup dla routera {r.name} OK", True) 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 * * *" cron_used = s.export_cron if s.export_cron else "0 */12 * * *"
print(f"[DEBUG] schedule_auto_export_job -> cron_schedule={cron_used}") 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ń # Konfiguracja APScheduler - harmonogram zadań
############################################################################### ###############################################################################
@@ -621,8 +580,8 @@ scheduler = BackgroundScheduler()
# Dodajemy z unikalnymi ID, co ułatwia re-schedulowanie # 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=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.add_job(func=scheduled_auto_backup, trigger='interval', days=1, id="auto_backup_job")
scheduler.start()
scheduler.start()
# Sprzątanie przy zamykaniu # Sprzątanie przy zamykaniu
atexit.register(lambda: scheduler.shutdown()) atexit.register(lambda: scheduler.shutdown())
@@ -643,7 +602,6 @@ def reschedule_jobs():
schedule_auto_export_job() schedule_auto_export_job()
schedule_retention_job() schedule_retention_job()
schedule_auto_binary_backup_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 # Filtr Jinja2 - basename
@@ -691,19 +649,6 @@ def get_data_folder_size():
pass pass
return total_size 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 # ROUTES
############################################################################### ###############################################################################
@@ -775,20 +720,19 @@ def advanced_schedule():
s = get_settings() s = get_settings()
if request.method == 'POST': if request.method == 'POST':
s.retention_cron = request.form.get('retention_cron', '').strip() 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.export_cron = request.form.get('export_cron', '').strip()
s.backup_retention_days = int(request.form.get('backup_retention_days', s.backup_retention_days)) # Checkbox: jeśli nie jest zaznaczony, nie pojawi się w formularzu, więc ustawiamy na False
s.log_retention_days = int(request.form.get('log_retention_days', s.log_retention_days))
s.enable_auto_export = True if request.form.get('enable_auto_export') == 'on' else False s.enable_auto_export = True if request.form.get('enable_auto_export') == 'on' else False
db.session.commit() db.session.commit()
reschedule_jobs() # Aktualizacja harmonogramu zadań reschedule_jobs() # Aktualizuje harmonogram zadań
flash("Ustawienia harmonogramu zostały zapisane.") flash("Zaawansowane ustawienia harmonogramu zostały zapisane.")
return redirect(url_for('advanced_schedule')) return redirect(url_for('advanced_schedule'))
return render_template('advanced_schedule.html', settings=s) return render_template('advanced_schedule.html', settings=s)
@app.route('/toggle_dark_mode') @app.route('/toggle_dark_mode')
def 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 session['dark_mode'] = not current_mode
return redirect(request.referrer or url_for('index')) 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() 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) return render_template('routers.html', user=user, routers=routers)
@app.route('/routers/add', methods=['GET','POST']) @app.route('/routers/add', methods=['GET','POST'])
@login_required @login_required
def add_router(): def add_router():
@@ -899,8 +845,8 @@ def router_export(router_id):
notify(get_settings(), f"Export {router.name} OK", True) notify(get_settings(), f"Export {router.name} OK", True)
log_operation(f"Export wykonany dla routera {router.name} at {datetime.utcnow()}") log_operation(f"Export wykonany dla routera {router.name} at {datetime.utcnow()}")
if request.headers.get("X-Requested-With") == "XMLHttpRequest": if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return {"status": "success", "message": "export zakończony."} return {"status": "success", "message": "Eksport zakończony."}
flash("Export zakończony.") flash("Eksport zakończony.")
except Exception as e: except Exception as e:
notify(get_settings(), f"Export {router.name} FAIL: {e}", False) notify(get_settings(), f"Export {router.name} FAIL: {e}", False)
log_operation(f"Export dla routera {router.name} FAILED: {e}") log_operation(f"Export dla routera {router.name} FAILED: {e}")
@@ -920,8 +866,7 @@ def router_backup(router_id):
try: try:
backup_name = f"{router.name}_{router.id}_{datetime.now():%Y%m%d_%H%M%S}" backup_name = f"{router.name}_{router.id}_{datetime.now():%Y%m%d_%H%M%S}"
local_path = ssh_backup(router, backup_name) 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')
b = Backup(router_id=router.id, file_path=local_path, backup_type='binary', checksum=checksum)
db.session.add(b) db.session.add(b)
db.session.commit() db.session.commit()
notify(get_settings(), f"Backup {router.name} OK", True) 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() b = Backup.query.filter_by(id=backup_id, router_id=router.id, backup_type='binary').first()
if not b: if not b:
flash("Nie znaleziono backupu binarnego.") flash("Nie znaleziono backupu binarnego.")
#return redirect(url_for('router_details', router_id=router.id)) 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)
try: 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()}") log_operation(f"Backup {os.path.basename(b.file_path)} wgrany do routera {router.name} at {datetime.utcnow()}")
flash("Plik backupu wgrany do routera.") flash("Plik backupu wgrany do routera.")
except Exception as e: except Exception as e:
flash(f"Błąd wgrywania: {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') next_url = request.form.get('next') or request.referrer or url_for('dashboard')
return redirect(next_url) return redirect(next_url)
@@ -1028,6 +964,7 @@ def export_all_routers():
flash(" | ".join(messages)) flash(" | ".join(messages))
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
# Nowa podstrona: diff selector
@app.route('/diff_selector', methods=['GET', 'POST']) @app.route('/diff_selector', methods=['GET', 'POST'])
@login_required @login_required
def diff_selector(): def diff_selector():
@@ -1047,11 +984,13 @@ def diff_selector():
def all_files(): def all_files():
user = get_current_user() user = get_current_user()
query = Backup.query.join(Router).filter(Router.owner_id == user.id) query = Backup.query.join(Router).filter(Router.owner_id == user.id)
# Filtrowanie wyszukiwanie po nazwie pliku (zastosowanie filtru "basename") # Filtrowanie wyszukiwanie po nazwie pliku (zastosowanie filtru "basename")
search = request.args.get('search', '') search = request.args.get('search', '')
if search: if search:
query = query.filter(Backup.file_path.ilike(f"%{search}%")) query = query.filter(Backup.file_path.ilike(f"%{search}%"))
# Sortowanie sort_by i order
sort_by = request.args.get('sort_by', 'created_at') sort_by = request.args.get('sort_by', 'created_at')
order = request.args.get('order', 'desc') order = request.args.get('order', 'desc')
if sort_by not in ['created_at', 'file_path']: if sort_by not in ['created_at', 'file_path']:
@@ -1081,7 +1020,7 @@ def view_export(backup_id):
flash("Brak dostępu do backupu.") flash("Brak dostępu do backupu.")
return redirect(url_for('all_files')) return redirect(url_for('all_files'))
if b.backup_type != 'export': 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')) return redirect(url_for('all_files'))
try: try:
with open(b.file_path, 'r', encoding='utf-8') as f: 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.") flash("Nie skonfigurowano ustawień SMTP w panelu.")
return redirect(url_for('settings_view')) return redirect(url_for('settings_view'))
subject = f"RouterOS Export: {os.path.basename(b.file_path)}" 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, 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): s.smtp_login, subject, body, b.file_path):
flash("Wysłano export mailem.") flash("Wysłano eksport mailem.")
else: else:
flash("Błąd wysyłki mailowej.") flash("Błąd wysyłki mailowej.")
#return redirect(url_for('router_details', router_id=b.router_id)) #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_port = int(request.form.get('smtp_port', '587'))
s.smtp_login = request.form.get('smtp_login', '') s.smtp_login = request.form.get('smtp_login', '')
s.smtp_password = request.form.get('smtp_password', '') s.smtp_password = request.form.get('smtp_password', '')
s.recipient_email = request.form.get('recipient_email', '')
db.session.commit() 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.") flash("Zapisano ustawienia.")
return redirect(url_for('settings_view')) return redirect(url_for('settings_view'))
return render_template('settings.html', settings=s) return render_template('settings.html', settings=s)
@@ -1270,6 +1208,7 @@ def diff_view(backup_id1, backup_id2):
lineterm='' lineterm=''
)) ))
diff_text = "\n".join(diff_lines) diff_text = "\n".join(diff_lines)
return render_template('diff.html', diff_text=diff_text, backup1=b1, backup2=b2) return render_template('diff.html', diff_text=diff_text, backup1=b1, backup2=b2)
@app.route('/routers/all_backup', methods=['POST']) @app.route('/routers/all_backup', methods=['POST'])
@@ -1362,7 +1301,7 @@ def change_password():
flash("Nowe hasło i potwierdzenie nie są zgodne.") flash("Nowe hasło i potwierdzenie nie są zgodne.")
return redirect(url_for('change_password')) return redirect(url_for('change_password'))
user.password_hash = pwd_context.hash(new_password) user.password_hash = bcrypt.hash(new_password)
db.session.commit() db.session.commit()
flash("Hasło zostało zmienione pomyślnie.") flash("Hasło zostało zmienione pomyślnie.")
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
@@ -1381,84 +1320,17 @@ def test_connection(router_id):
except Exception as e: except Exception as e:
flash(f"Błąd testu połączenia: {e}") flash(f"Błąd testu połączenia: {e}")
return redirect(url_for('routers_list')) return redirect(url_for('routers_list'))
# Jeśli wywołanie zawiera parametr modal=1, zwracamy widok dla modalu
if request.args.get("modal") == "1": if request.args.get("modal") == "1":
return render_template("test_connection_modal.html", router=router, result=result) return render_template("test_connection_modal.html", router=router, result=result)
return render_template("test_connection.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__': if __name__ == '__main__':
with app.app_context(): 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()) atexit.register(lambda: scheduler.shutdown())
app.run(host='0.0.0.0', port=5581, use_reloader=False, debug=True) 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 paramiko
APScheduler APScheduler
requests requests
#gunicorn gunicorn
flask_wtf flask_wtf
gevent gevent
#croniter #croniter
waitress

View File

@@ -1,14 +1,18 @@
[Unit] [Unit]
Description=RouterOS Backup Waitress Service Description=RouterOS Backup Application
After=network.target After=network.target
[Service] [Service]
#User=routeros #User=www-data # Zmień na odpowiedniego użytkownika
#Group=routeros #Group=www-data
WorkingDirectory=/opt/routeros_backup 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 Restart=always
Environment=PYTHONUNBUFFERED=1 RestartSec=5
[Install] [Install]
WantedBy=multi-user.target 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" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container mt-5">
<div class="card border-0 shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-light"> <div class="card-header">
<h4 class="mb-0">Dodaj nowe urządzenie</h4> <h2 class="mb-0">Dodaj nowe urządzenie</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST"> <form method="POST">
@@ -24,16 +24,18 @@
<input type="text" class="form-control" id="ssh_user" name="ssh_user" required> <input type="text" class="form-control" id="ssh_user" name="ssh_user" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="ssh_key" class="form-label"><b>Klucz prywatny</b></label><br> <label for="ssh_key" class="form-label">
<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> <b>Klucz prywatny</b> | Wklej wraz z <code>-----BEGIN RSA PRIVATE KEY-----</code> i <code>-----END RSA PRIVATE KEY-----</code><br>
<textarea class="form-control mt-2" id="ssh_key" name="ssh_key" rows="4"></textarea> 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>
<div class="mb-3"> <div class="mb-3">
<label for="ssh_password" class="form-label"><b>Hasło SSH</b></label><br> <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> 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 mt-2" id="ssh_password" name="ssh_password"> <input type="password" class="form-control" id="ssh_password" name="ssh_password">
</div> </div>
<button type="submit" class="btn btn-primary">Dodaj urządzenie</button> <button type="submit" class="btn btn-primary">Dodaj</button>
</form> </form>
</div> </div>
</div> </div>

View File

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

View File

@@ -3,8 +3,8 @@
<div class="container my-4"> <div class="container my-4">
<h2 class="text-center mb-4">Lista wszystkich backupów</h2> <h2 class="text-center mb-4">Lista wszystkich backupów</h2>
<!-- Karta filtra --> <!-- Formularz filtrowania -->
<div class="card mb-4 shadow-sm border-0"> <div class="card mb-4 shadow-sm">
<div class="card-body"> <div class="card-body">
<form method="GET" action="{{ url_for('all_files') }}" class="row g-2"> <form method="GET" action="{{ url_for('all_files') }}" class="row g-2">
<div class="col-md-4"> <div class="col-md-4">
@@ -29,11 +29,11 @@
</div> </div>
</div> </div>
<!-- Karta tabeli backupów --> <!-- Tabela z backupami -->
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <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"> <thead class="table-dark">
<tr> <tr>
<th style="width: 2%;"><input type="checkbox" id="select_all"></th> <th style="width: 2%;"><input type="checkbox" id="select_all"></th>
@@ -63,23 +63,18 @@
<span class="badge bg-secondary">{{ file.backup_type }}</span> <span class="badge bg-secondary">{{ file.backup_type }}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>{{ file.file_path|basename }}</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.created_at.strftime("%Y-%m-%d %H:%M:%S") }}</td> <td>{{ file.created_at.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td>{{ file.file_path|filesize }}</td> <td>{{ file.file_path|filesize }}</td>
<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> <i class="bi bi-download"></i>
</a> </a>
</td> </td>
<td> <td>
<form action="{{ url_for('send_by_email', backup_id=file.id) }}" method="POST" class="d-inline"> <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> <i class="bi bi-envelope"></i>
</button> </button>
</form> </form>
@@ -87,7 +82,8 @@
<td> <td>
{% if file.backup_type == 'binary' %} {% 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"> <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> <i class="bi bi-upload"></i>
</button> </button>
</form> </form>
@@ -97,7 +93,7 @@
</td> </td>
<td> <td>
{% if file.backup_type == 'export' %} {% 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> <i class="bi bi-eye"></i>
</a> </a>
{% else %} {% else %}
@@ -106,7 +102,8 @@
</td> </td>
<td> <td>
<form action="{{ url_for('delete_backup', backup_id=file.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć backup?');"> <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> <i class="bi bi-trash"></i>
</button> </button>
</form> </form>
@@ -120,21 +117,22 @@
</div> </div>
</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"> <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"> <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 <i class="bi bi-file-earmark-zip"></i> Pobierz zip zaznaczonych
</button> </button>
<button type="submit" name="action" value="delete" class="btn btn-danger" onclick="return confirm('Na pewno usunąć zaznaczone pliki?');"> <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 <i class="bi bi-trash"></i> Usuń zaznaczone backupy
</button> </button>
</form> </form>
</div> </div>
<script> <script>
document.getElementById('select_all').addEventListener('change', function(e) { document.getElementById('select_all').addEventListener('change', function(e) {
var checkboxes = document.querySelectorAll('input[name="backup_id"]'); 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> </script>
{% endblock %} {% endblock %}

View File

@@ -1,392 +1,116 @@
<!DOCTYPE html> <!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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Backup RouterOS App</title>
<title>Backup RouterOS</title>
<!-- Bootstrap CSS --> <!-- 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 */ <style>
.dark-mode body { .dark-mode body {
background-color: #121212; background-color: #222;
color: #ffffff; color: #ffffff;
} }
.dark-mode a, .dark-mode a, .dark-mode a:hover {
.dark-mode a:hover {
color: #ddd; color: #ddd;
} }
.dark-mode .navbar, .dark-mode .table {
/* 3) Nawigacja i menu w trybie ciemnym */
.dark-mode .navbar,
.dark-mode .navbar-nav,
.dark-mode .dropdown-menu {
background-color: #333 !important; background-color: #333 !important;
color: #fff; 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-add { color: green; }
.diff-rem { color: red; } .diff-rem { color: red; }
.dark-mode .text-muted {
color: #aaa !important; /* zamiast #aaa możesz wybrać #bbb, #ccc itp. */
}
</style> </style>
{% block head %}{% endblock %}
</head> </head>
<body class="d-flex flex-column"> <body>
<!-- Navbar --> <nav class="navbar navbar-expand navbar-dark bg-dark mb-4">
<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">
<div class="container-fluid"> <div class="container-fluid">
<a href="/" class="navbar-brand">Backup RouterOS</a> <a href="{{ url_for('index') }}" class="navbar-brand">Backup RouterOS</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" <div>
aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation"> {% if session.user_id %}
<span class="navbar-toggler-icon"></span> <a href="{{ url_for('dashboard') }}" class="btn btn-secondary me-2">Dashboard</a>
</button> <a href="{{ url_for('routers_list') }}" class="btn btn-secondary me-2">Urządzenia</a>
<div class="collapse navbar-collapse" id="navbarNavDropdown"> <a href="{{ url_for('diff_selector') }}" class="btn btn-secondary me-2">Diff selector</a>
{% if session.get('user_id') %} <a href="{{ url_for('all_files') }}" class="btn btn-secondary me-2">Wszystkie pliki</a>
<ul class="navbar-nav me-auto"> <a href="{{ url_for('settings_view') }}" class="btn btn-secondary me-2">Ustawienia</a>
<!-- Dashboard --> <a href="{{ url_for('advanced_schedule') }}" class="btn btn-secondary me-2">Harmonogram</a>
<li class="nav-item"> <a href="{{ url_for('change_password') }}" class="btn btn-secondary me-2">Zmiana hasła</a>
<a class="nav-link" href="{{ url_for('dashboard') }}">Dashboard</a> <a href="{{ url_for('logout') }}" class="btn btn-secondary me-2">Wyloguj</a>
</li> {% else %}
<!-- Urządzenia dropdown --> <a href="{{ url_for('login') }}" class="btn btn-secondary me-2">Zaloguj</a>
<li class="nav-item dropdown"> <a href="{{ url_for('register') }}" class="btn btn-secondary me-2">Utwórz konto</a>
<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 %} {% endif %}
<!--<a href="{{ url_for('toggle_dark_mode') }}" class="btn btn-warning">Toggle Dark Mode</a>-->
<!-- 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>
{% 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>
{% endif %}
</ul>
</div> </div>
</div> </div>
</nav> </nav>
<div class="container">
{% with messages = get_flashed_messages() %}
<!-- Główna zawartość -->
<main class="container mb-5">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, msg in messages %} <div class="alert alert-info">
{% set bs_cat = bootstrap_alert_category(category) %} {% for msg in messages %}
<div class="alert alert-{{ bs_cat }} alert-dismissible fade show" role="alert"> <div>{{ msg }}</div>
{{ msg }} {% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> </div>
</div>
{% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </div>
<!-- Modal Test Połączenia --> <!-- Modal Test Połączenia -->
<div class="modal fade" id="testConnectionModal" tabindex="-1" aria-labelledby="testConnectionModalLabel" aria-hidden="true"> <div class="modal fade" id="testConnectionModal" tabindex="-1" aria-labelledby="testConnectionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="testConnectionModalLabel">Test Połączenia</h5> <h5 class="modal-title" id="testConnectionModalLabel">Test Połączenia</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div> </div>
<div class="modal-body" id="testConnectionModalBody"> <div class="modal-body" id="testConnectionModalBody">
<!-- Zawartość ładowana przez AJAX --> <!-- Zawartość zostanie załadowana przez AJAX -->
</div>
</div> </div>
</div> </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.2.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Dodatkowe skrypty -->
<script> <script>
// Funkcja do wczytywania modalu testu połączenia function ajaxExport(router_id) {
function openTestConnectionModal(routerId) { fetch("/router/" + router_id + "/export", {
fetch('/router/' + routerId + '/test_connection?modal=1') method: "POST",
.then(response => response.text()) headers: {"X-Requested-With": "XMLHttpRequest"}
.then(html => { })
document.getElementById('testConnectionModalBody').innerHTML = html; .then(response => response.json())
var myModal = new bootstrap.Modal(document.getElementById('testConnectionModal')); .then(data => {
myModal.show(); if(data.status === "success"){
}) alert("Eksport wykonany: " + data.message);
.catch(error => { // Możesz też zaktualizować część strony dynamicznie
console.error("Błąd ładowania modalu: ", error); } else {
alert("Wystąpił błąd podczas ładowania danych."); alert("Błąd eksportu: " + data.message);
}); }
})
.catch(error => {
console.error("Błąd AJAX:", error);
alert("Wystąpił błąd.");
});
} }
</script> </script>
<script>
{% block scripts %}{% endblock %} function openTestConnectionModal(routerId) {
fetch('/router/' + routerId + '/test_connection?modal=1')
.then(response => response.text())
.then(html => {
document.getElementById('testConnectionModalBody').innerHTML = html;
var myModal = new bootstrap.Modal(document.getElementById('testConnectionModal'));
myModal.show();
})
.catch(error => {
console.error("Błąd ładowania modalu: ", error);
alert("Wystąpił błąd podczas ładowania danych.");
});
}
</script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

@@ -1,47 +1,28 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
<div class="card border-0 shadow-sm"> <h2>Porównanie: {{ backup1.file_path|basename }} vs {{ backup2.file_path|basename }}</h2>
<div class="card-header bg-light"> <hr>
<h4 class="mb-0"> <div id="diffContainer"></div>
Porównanie: {{ backup1.file_path|basename }} vs {{ backup2.file_path|basename }} <a href="{{ url_for('router_details', router_id=backup1.router_id) }}" class="btn btn-secondary mt-3">Powrót</a>
</h4>
</div>
<div class="card-body">
<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> </div>
<!-- diff2html resources --> <!-- Dodajemy diff2html -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html@3.4.4/bundles/css/diff2html.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css" />
<script src="https://cdn.jsdelivr.net/npm/diff2html@3.4.4/bundles/js/diff2html.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html.min.js"></script>
<script> <script>
document.addEventListener("DOMContentLoaded", function() { document.addEventListener("DOMContentLoaded", function() {
var diffText = `{{ diff_text|e }}`; // Upewnij się, że diff_text jest poprawnie escapowany
var targetElement = document.getElementById("diffContainer"); var diffText = `{{ diff_text|e }}`;
var configuration = { var targetElement = document.getElementById("diffContainer");
drawFileList: true, var configuration = {
matching: 'lines', drawFileList: true,
outputFormat: 'line-by-line' matching: 'lines',
}; outputFormat: 'line-by-line'
var diffHtml = Diff2Html.html(diffText, configuration); };
targetElement.innerHTML = diffHtml; 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> </script>
{% endblock %} {% endblock %}

View File

@@ -1,10 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
<div class="card shadow-sm border-0"> <h2 class="text-center mb-4">Porównanie backupów (Diff)</h2>
<div class="card-header bg-light d-flex align-items-center"> <div class="card shadow-sm">
<h4 class="mb-0">Porównanie backupów (Diff)</h4>
</div>
<div class="card-body"> <div class="card-body">
<form action="{{ url_for('diff_selector') }}" method="POST" id="diffForm"> <form action="{{ url_for('diff_selector') }}" method="POST" id="diffForm">
<div class="row mb-3"> <div class="row mb-3">
@@ -31,10 +29,8 @@
</select> </select>
</div> </div>
</div> </div>
<div class="text-center mt-4"> <div class="text-center">
<button type="submit" class="btn btn-primary btn-lg"> <button type="submit" class="btn btn-primary btn-lg">Porównaj backupy</button>
Porównaj backupy
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -43,12 +39,12 @@
<script> <script>
document.getElementById("diffForm").addEventListener("submit", function(event) { document.getElementById("diffForm").addEventListener("submit", function(event) {
var backup1 = document.getElementById("backup1").value; var backup1 = document.getElementById("backup1").value;
var backup2 = document.getElementById("backup2").value; var backup2 = document.getElementById("backup2").value;
if (backup1 === backup2) { if(backup1 === backup2) {
event.preventDefault(); event.preventDefault();
alert("Wybierz dwa różne backupy do porównania."); alert("Wybierz dwa różne backupy do porównania.");
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container mt-5">
<div class="card border-0 shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-light"> <div class="card-header">
<h4 class="mb-0">Edycja urządzenia</h4> <h2 class="mb-0">Edycja urządzenia</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST"> <form method="POST">
@@ -25,15 +25,15 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="ssh_key" class="form-label"> <label for="ssh_key" class="form-label">
<b>Klucz prywatny</b> <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>
</label><br> Pozostaw puste jeśli ten RouterOS będzie używał <a href="{{ url_for('settings_view') }}">klucza globalnego</a>
<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> </label>
<textarea class="form-control mt-2" id="ssh_key" name="ssh_key" rows="4">{{ router.ssh_key }}</textarea> <textarea class="form-control" id="ssh_key" name="ssh_key" rows="4">{{ router.ssh_key }}</textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="ssh_password" class="form-label"><b>Hasło SSH</b></label><br> <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> 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 mt-2" id="ssh_password" name="ssh_password" value="{{ router.ssh_password }}"> <input type="password" class="form-control" id="ssh_password" name="ssh_password" value="{{ router.ssh_password }}">
</div> </div>
<button type="submit" class="btn btn-success">Zapisz zmiany</button> <button type="submit" class="btn btn-success">Zapisz zmiany</button>
</form> </form>

View File

@@ -2,11 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height: 80vh;"> <div class="d-flex flex-column align-items-center justify-content-center" style="min-height: 80vh;">
<div class="text-center"> <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;"> <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> <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> <p class="lead">Zarządzaj backupami swoich urządzeń RouterOS w prosty sposób.</p>
<div class="mt-4"> <div class="mt-4">
@@ -16,4 +12,3 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<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="container my-4"> <div class="container my-4">
<div class="mb-3 text-end"> <!-- Informacje o routerze -->
{% if current_view == 'v1' %} <div class="card shadow-sm mb-4">
<a href="{{ url_for('router_details', router_id=router.id, view='v2') }}" class="btn btn-outline-secondary">Przełącz na widok v2</a> <div class="card-header">
{% else %} <h2 class="mb-0">Router: {{ router.name }}</h2>
<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> </div>
<div class="card-body"> <div class="card-body">
<p> <p>
@@ -20,13 +20,14 @@
<strong>SSH User:</strong> {{ router.ssh_user }} <strong>SSH User:</strong> {{ router.ssh_user }}
</p> </p>
<div class="d-flex flex-wrap gap-2"> <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"> <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>
<form action="{{ url_for('router_backup', router_id=router.id) }}" method="POST" class="d-inline"> <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> </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> </div>
</div> </div>
@@ -34,172 +35,160 @@
<!-- Zakładki z backupami --> <!-- Zakładki z backupami -->
<ul class="nav nav-tabs" id="routerTab" role="tablist"> <ul class="nav nav-tabs" id="routerTab" role="tablist">
<li class="nav-item" role="presentation"> <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"> <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>
Pliki /export
</button>
</li> </li>
<li class="nav-item" role="presentation"> <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"> <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>
Pliki binarne
</button>
</li> </li>
</ul> </ul>
<div class="tab-content" id="routerTabContent"> <div class="tab-content" id="routerTabContent">
<!-- Zakładka /export --> <!-- Zakładka /export -->
<div class="tab-pane fade show active" id="export" role="tabpanel" aria-labelledby="export-tab"> <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"> <div class="card-body">
{% if export_backups %} {% if export_backups %}
<!-- Formularz masowych akcji dla eksportów --> <!-- Formularz masowych akcji dla eksportów -->
<form id="export_mass_actions_form" action="{{ url_for('download_zip') }}" method="POST" class="mb-3"> <form id="export_mass_actions_form" action="{{ url_for('download_zip') }}" method="POST" class="mb-3">
<div class="d-flex justify-content-end"> <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) <i class="bi bi-file-earmark-zip"></i> Pobierz zaznaczone (.zip)
</button> </button>
</div> </div>
</form> </form>
<!-- Tabela z eksportami z podzielonymi kolumnami akcji -->
<div class="table-responsive"> <table class="table table-bordered table-striped">
<table class="table table-bordered table-striped align-middle"> <thead class="table-dark">
<thead class="table-dark"> <tr>
<tr> <th style="width: 3%;"><input type="checkbox" id="select_all_export"></th>
<th style="width: 3%;"><input type="checkbox" id="select_all_export"></th> <th>Nazwa pliku</th>
<th>Nazwa pliku</th> <th>Rozmiar</th>
<th>Rozmiar</th> <th>Data</th>
<th>Data</th> <th>Diff</th>
<th>Diff</th> <th>Pobierz</th>
<th>Pobierz</th> <th>Podgląd</th>
<th>Podgląd</th> <th>Wyślij mailem</th>
<th>Wyślij mailem</th> <th>Usuń</th>
<th>Usuń</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {% for b in export_backups %}
{% for b in export_backups %} <tr>
<tr> <td>
<td> <input type="checkbox" name="backup_id" value="{{ b.id }}" form="export_mass_actions_form">
<input type="checkbox" name="backup_id" value="{{ b.id }}" form="export_mass_actions_form"> </td>
</td> <td>{{ b.file_path|basename }}</td>
<td>{{ b.file_path|basename }}</td> <td>{{ b.file_path|filesize }}</td>
<td>{{ b.file_path|filesize }}</td> <td>{{ b.created_at.strftime("%Y-%m-%d %H:%M:%S") }}</td>
<td>{{ b.created_at.strftime("%Y-%m-%d %H:%M:%S") }}</td> <td>
<td> {% if loop.index0 > 0 %}
{% if loop.index0 > 0 %} <a href="{{ url_for('diff_view', backup_id1=b.id, backup_id2=export_backups[0].id) }}" class="btn btn-sm btn-info">Diff</a>
<a href="{{ url_for('diff_view', backup_id1=b.id, backup_id2=export_backups[0].id) }}" class="btn btn-sm btn-info">Diff</a> {% else %}
{% else %} <small>Brak nowszego</small>
<small>Brak nowszego</small> {% endif %}
{% endif %} </td>
</td> <td>
<td> <a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-lg btn-info" title="Pobierz">
<a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-info btn-sm" title="Pobierz"> <i class="bi bi-download"></i>
<i class="bi bi-download"></i> </a>
</a> </td>
</td> <td>
<td> <a href="{{ url_for('view_export', backup_id=b.id) }}" class="btn btn-lg btn-outline-primary" title="Podgląd">
<a href="{{ url_for('view_export', backup_id=b.id) }}" class="btn btn-outline-primary btn-sm" title="Podgląd"> <i class="bi bi-eye"></i>
<i class="bi bi-eye"></i> </a>
</a> </td>
</td> <td>
<td> <form action="{{ url_for('send_by_email', backup_id=b.id) }}" method="POST" class="d-inline">
<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) }}">
<input type="hidden" name="next" value="{{ url_for('router_details', router_id=router.id) }}"> <button type="submit" class="btn btn-lg btn-primary" title="Wyślij mailem">
<button type="submit" class="btn btn-primary btn-sm" title="Wyślij mailem"> <i class="bi bi-envelope"></i>
<i class="bi bi-envelope"></i> </button>
</button> </form>
</form> </td>
</td> <td>
<td> <form action="{{ url_for('delete_backup', backup_id=b.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć backup?');">
<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) }}">
<input type="hidden" name="next" value="{{ url_for('router_details', router_id=router.id) }}"> <button type="submit" class="btn btn-lg btn-danger" title="Usuń">
<button type="submit" class="btn btn-danger btn-sm" title="Usuń"> <i class="bi bi-trash"></i>
<i class="bi bi-trash"></i> </button>
</button> </form>
</form> </td>
</td> </tr>
</tr> {% endfor %}
{% endfor %} </tbody>
</tbody> </table>
</table>
</div>
{% else %} {% else %}
<p class="text-muted">Brak plików /export.</p> <p class="text-muted">Brak plików /export.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Zakładka backupów binarnych --> <!-- Zakładka backupów binarnych -->
<div class="tab-pane fade" id="binary" role="tabpanel" aria-labelledby="binary-tab"> <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"> <div class="card-body">
{% if binary_backups %} {% if binary_backups %}
<!-- Formularz masowych akcji dla backupów binarnych --> <!-- Formularz masowych akcji dla backupów binarnych -->
<form id="binary_mass_actions_form" action="{{ url_for('download_zip') }}" method="POST" class="mb-3"> <form id="binary_mass_actions_form" action="{{ url_for('download_zip') }}" method="POST" class="mb-3">
<div class="d-flex justify-content-end"> <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) <i class="bi bi-file-earmark-zip"></i> Pobierz zaznaczone (.zip)
</button> </button>
</div> </div>
</form> </form>
<!-- Tabela z backupami binarnymi z podzielonymi kolumnami akcji -->
<div class="table-responsive"> <table class="table table-bordered table-striped">
<table class="table table-bordered table-striped align-middle"> <thead class="table-dark">
<thead class="table-dark"> <tr>
<tr> <th style="width: 3%;"><input type="checkbox" id="select_all_binary"></th>
<th style="width: 3%;"><input type="checkbox" id="select_all_binary"></th> <th>Nazwa pliku</th>
<th>Nazwa pliku</th> <th>Rozmiar</th>
<th>Rozmiar</th> <th>Data</th>
<th>Data</th> <th>Pobierz</th>
<th>Pobierz</th> <th>Wgraj do routera</th>
<th>Wgraj do routera</th> <th>Wyślij mailem</th>
<th>Wyślij mailem</th> <th>Usuń</th>
<th>Usuń</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {% for b in binary_backups %}
{% for b in binary_backups %} <tr>
<tr> <td>
<td> <input type="checkbox" name="backup_id" value="{{ b.id }}" form="binary_mass_actions_form">
<input type="checkbox" name="backup_id" value="{{ b.id }}" form="binary_mass_actions_form"> </td>
</td> <td>{{ b.file_path|basename }}</td>
<td> <td>{{ b.file_path|filesize }}</td>
<span data-bs-toggle="tooltip" title="Checksum: {{ b.checksum }}">{{ b.file_path|basename }}</span> <td>{{ b.created_at.strftime("%Y-%m-%d %H:%M:%S") }}</td>
</td> <td>
<td>{{ b.file_path|filesize }}</td> <a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-lg btn-info" title="Pobierz">
<td>{{ b.created_at.strftime("%Y-%m-%d %H:%M:%S") }}</td> <i class="bi bi-download"></i>
<td> </a>
<a href="{{ url_for('download_file', filename=b.file_path|basename) }}" class="btn btn-info btn-sm" title="Pobierz"> </td>
<i class="bi bi-download"></i> <td>
</a> <form action="{{ url_for('upload_backup', router_id=router.id, backup_id=b.id) }}" method="POST" class="d-inline">
</td> <button type="submit" class="btn btn-lg btn-secondary" title="Wgraj do routera">
<td> <i class="bi bi-upload"></i>
<form action="{{ url_for('upload_backup', router_id=router.id, backup_id=b.id) }}" method="POST" class="d-inline"> </button>
<button type="submit" class="btn btn-secondary btn-sm" title="Wgraj do routera"> </form>
<i class="bi bi-upload"></i> </td>
</button> <td>
</form> <form action="{{ url_for('send_by_email', backup_id=b.id) }}" method="POST" class="d-inline">
</td> <button type="submit" class="btn btn-lg btn-primary" title="Wyślij mailem">
<td> <i class="bi bi-envelope"></i>
<form action="{{ url_for('send_by_email', backup_id=b.id) }}" method="POST" class="d-inline"> </button>
<button type="submit" class="btn btn-primary btn-sm" title="Wyślij mailem"> </form>
<i class="bi bi-envelope"></i> </td>
</button> <td>
</form> <form action="{{ url_for('delete_backup', backup_id=b.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć backup?');">
</td> <input type="hidden" name="next" value="{{ url_for('router_details', router_id=router.id) }}">
<td> <button type="submit" class="btn btn-lg btn-danger" title="Usuń">
<form action="{{ url_for('delete_backup', backup_id=b.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć backup?');"> <i class="bi bi-trash"></i>
<input type="hidden" name="next" value="{{ url_for('router_details', router_id=router.id) }}"> </button>
<button type="submit" class="btn btn-danger btn-sm" title="Usuń"> </form>
<i class="bi bi-trash"></i> </td>
</button> </tr>
</form> {% endfor %}
</td> </tbody>
</tr> </table>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<p class="text-muted">Brak plików binarnych.</p> <p class="text-muted">Brak plików binarnych.</p>
{% endif %} {% endif %}
@@ -213,14 +202,18 @@
<script> <script>
document.getElementById('select_all_export').addEventListener('change', function(e) { document.getElementById('select_all_export').addEventListener('change', function(e) {
var checkboxes = document.querySelectorAll('input[name="backup_id"][form="export_mass_actions_form"]'); 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) { document.getElementById('select_all_binary').addEventListener('change', function(e) {
var checkboxes = document.querySelectorAll('input[name="backup_id"][form="binary_mass_actions_form"]'); 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')); var triggerTabList = [].slice.call(document.querySelectorAll('#routerTab button'));
triggerTabList.forEach(function (triggerEl) { triggerTabList.forEach(function (triggerEl) {
var tabTrigger = new bootstrap.Tab(triggerEl); var tabTrigger = new bootstrap.Tab(triggerEl);
@@ -229,11 +222,5 @@ triggerTabList.forEach(function (triggerEl) {
tabTrigger.show(); tabTrigger.show();
}); });
}); });
// Inicjalizacja tooltipów
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,67 +1,63 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
<div class="card border-0 shadow-sm"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="card-header bg-light d-flex justify-content-between align-items-center"> <h2>Moje Routery</h2>
<h4 class="mb-0">Lista urządzeń</h4> <a href="{{ url_for('add_router') }}" class="btn btn-success">
<a href="{{ url_for('add_router') }}" class="btn btn-success"> <i class="bi bi-plus-lg"></i> Dodaj nowe urządzenie
<i class="bi bi-plus-lg"></i> Dodaj nowe urządzenie </a>
</a> </div>
</div> <div class="table-responsive">
<div class="card-body"> <table class="table table-striped table-hover">
<div class="table-responsive-sm"> <thead class="table-primary">
<table class="table table-striped table-hover align-middle"> <tr>
<thead class="table-primary"> <th>Nazwa</th>
<tr> <th>Host</th>
<th>Nazwa</th> <th>Port</th>
<th>Host</th> <th>Exporty</th>
<th>Port</th> <th>Backupy binarne</th>
<th>Exporty</th> <th>Test Połączenia</th>
<th>Backupy binarne</th> <th>Akcje</th>
<th>Test Połączenia</th> </tr>
<th>Akcje</th> </thead>
</tr> <tbody>
</thead> {% for router in routers %}
<tbody> <tr>
{% for router in routers %} <td>{{ router.name }}</td>
<tr> <td>{{ router.host }}</td>
<td>{{ router.name }}</td> <td>{{ router.port }}</td>
<td>{{ router.host }}</td> <td>
<td>{{ router.port }}</td> <span class="badge bg-success">
<td> {{ router.backups|selectattr("backup_type", "equalto", "export")|list|length }}
<span class="badge bg-success"> </span>
{{ router.backups|selectattr("backup_type", "equalto", "export")|list|length }} </td>
</span> <td>
</td> <span class="badge bg-info">
<td> {{ router.backups|selectattr("backup_type", "equalto", "binary")|list|length }}
<span class="badge bg-info"> </span>
{{ router.backups|selectattr("backup_type", "equalto", "binary")|list|length }} </td>
</span> <td>
</td> <button type="button" class="btn btn-sm btn-info" onclick="openTestConnectionModal({{ router.id }})">
<td> <i class="bi bi-wifi"></i> Test
<button type="button" class="btn btn-sm btn-info" onclick="openTestConnectionModal({{ router.id }})"> </button>
<i class="bi bi-wifi"></i> Test </td>
</button> <td>
</td> <a href="{{ url_for('router_details', router_id=router.id) }}" class="btn btn-sm btn-primary">
<td> <i class="bi bi-eye"></i> Szczegóły
<a href="{{ url_for('router_details', router_id=router.id) }}" class="btn btn-sm btn-primary"> </a>
<i class="bi bi-eye"></i> Szczegóły <a href="{{ url_for('edit_router', router_id=router.id) }}" class="btn btn-sm btn-warning">
</a> <i class="bi bi-pencil"></i> Edytuj
<a href="{{ url_for('edit_router', router_id=router.id) }}" class="btn btn-sm btn-warning"> </a>
<i class="bi bi-pencil"></i> Edytuj <form action="{{ url_for('delete_router', router_id=router.id) }}" method="POST" class="d-inline">
</a> <button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Na pewno usunąć urządzenie?');">
<form action="{{ url_for('delete_router', router_id=router.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Na pewno usunąć urządzenie?');"> <i class="bi bi-trash"></i> Usuń
<button type="submit" class="btn btn-sm btn-danger"> </button>
<i class="bi bi-trash"></i> Usuń </form>
</button> </td>
</form> </tr>
</td> {% endfor %}
</tr> </tbody>
{% endfor %} </table>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

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