From c89c1efd6762eb6d79ad80055ce1aeb5fe46c220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 26 Feb 2025 14:29:36 +0100 Subject: [PATCH] functions and interface --- Dockerfile | 11 + README.md | 132 ++++++++ app.py | 220 ++++++++++++- docker-compose.yml | 13 + requirements.txt | 3 +- start.sh | 21 ++ templates/base.html | 306 +++++++++++++++--- ...set_password.html => change_password.html} | 2 +- templates/dashboard.html | 66 +++- templates/device_detail.html | 67 ++-- templates/routeros_changelog_tabs.html | 91 ++++++ 11 files changed, 850 insertions(+), 82 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 start.sh rename templates/{reset_password.html => change_password.html} (96%) create mode 100644 templates/routeros_changelog_tabs.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6ba83f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +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"] diff --git a/README.md b/README.md index e69de29..f6bb214 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,132 @@ +# RouterOS Update & Changelog Dashboard + +RouterOS Update & Changelog Dashboard to aplikacja webowa oparta na Flask, która służy do monitorowania aktualizacji systemu RouterOS dla urządzeń MikroTik. Aplikacja pobiera changelogi z [MikroTik Changelogs](https://mikrotik.com/download/changelogs) i prezentuje je w przyjaznym interfejsie z podziałem na wersje (6.x, 7.x) oraz kanały wydania (stable, rc, beta). Dodatkowo dashboard wyświetla statystyki dotyczące urządzeń, logów, historii aktualizacji i ostatnich zdarzeń. + +## Funkcjonalności + +- **Pobieranie i prezentacja changelogów** + - Automatyczne pobieranie changelogów z witryny MikroTik. + - Filtrowanie i zapisywanie wpisów tylko dla wersji 6.x i 7.x. + - Podział changelogów według kanałów wydania: stable, rc, beta. + - Formatowanie wersji (np. „7.182025” jest wyświetlane jako „7.18”) wraz z datą wydania. + - Prezentacja changelogów z wykorzystaniem Prism.js z obsługą trybu ciemnego/jasnego. + +- **Zarządzanie urządzeniami** + - Dodawanie, edycja i monitorowanie urządzeń MikroTik. + - Sprawdzanie aktualizacji oprogramowania na urządzeniach. + - Rejestrowanie logów i historii aktualizacji. + +- **Dashboard** + - Wyświetlanie liczby urządzeń, logów, urządzeń wymagających aktualizacji oraz wykonanych aktualizacji. + - Prezentacja najnowszych wersji systemu (RouterOS 7.x i 6.x) z opcją szybkiego przejścia do szczegółowego changeloga. + - Lista ostatnich zdarzeń z systemu. + +- **Powiadomienia** + - Wysyłanie powiadomień za pomocą Pushover i e-mail, gdy dostępne są nowe aktualizacje. + - Wbudowana detekcja anomalii na podstawie logów. + +- **Planowanie zadań** + - Automatyczne sprawdzanie urządzeń, pobieranie changelogów oraz czyszczenie starych logów dzięki APScheduler. + +## Technologie + +- **Flask** – framework webowy. +- **SQLAlchemy** – ORM do zarządzania bazą danych. +- **Flask-Login** – uwierzytelnianie użytkowników. +- **APScheduler** – planowanie zadań w tle. +- **BeautifulSoup** – parsowanie HTML do pobierania changelogów. +- **Prism.js** – prezentacja i podświetlanie składni (z trybem ciemnym/jasnym). +- **Bootstrap 5** – responsywny interfejs użytkownika. + +## Instalacja + +1. **Klonowanie repozytorium:** + + ```bash + git clone https://github.com/TwojeRepozytorium/routeros-update.git + cd routeros-update + +2. Utworzenie środowiska wirtualnego i instalacja zależności: + +``` python3 -m venv venv +source venv/bin/activate # Na Windows: venv\Scripts\activate +pip install -r requirements.txt + +3. Inicjalizacja bazy danych: (nie jest to potrzebne, tylko przy aktualizacji) + +Domyślnie aplikacja używa SQLite. Uruchom aplikację lub użyj Flask shell, aby utworzyć bazę: +``` +flask shell +>>> from app import db +>>> db.create_all() + +## Konfiguracja + +1. :**Plik konfiguracyjny::** +W pliku app.py ustaw odpowiednią wartość dla SECRET_KEY oraz, jeśli potrzebujesz, zmodyfikuj SQLALCHEMY_DATABASE_URI. + +2. :**Powiadomienia::** +Po rejestracji użytkownika skonfiguruj ustawienia powiadomień (Pushover, SMTP) w sekcji ustawień. + +3. :**Tryb ciemny/jasny::** +Globalny przełącznik trybu (w menu) umożliwia zmianę wyglądu całej aplikacji, w tym motywu Prism.js wykorzystywanego do prezentacji changelogów. + +## Instalacja + +# Lokalnie + +1. **Klonowanie repozytorium:** + ```bash + git clone https://github.com/TwojeRepozytorium/routeros-update.git + cd routeros-update + +**Klonowanie repozytorium:** + +```python3 -m venv venv +source venv/bin/activate # Na Windows: venv\Scripts\activate +pip install -r requirements.txt + +3. **Uruchomienie aplikacji:** + +```python app.py + +Aplikacja będzie dostępna pod adresem: http://0.0.0.0:5582 + +# Uruchomienie w Dockerze + +Aplikację można uruchomić w kontenerze Docker (lub Podman). W repozytorium znajdują się następujące pliki: + +1. Dockerfile – definicja obrazu aplikacji. +2. docker-compose.yml – konfiguracja kontenera. +3. start.sh – skrypt uruchamiający kontenery (wybiera podman-compose, jeśli dostępny, lub docker-compose). + +Aby uruchomić aplikację w Dockerze, wykonaj: + +```./start.sh + +Skrypt zatrzyma i usunie poprzednie kontenery, odbuduje obraz i uruchomi aplikację w tle. + +## Użytkowanie + +1. Dashboard: +Na stronie głównej dashboardu możesz zobaczyć statystyki urządzeń, liczbę logów, urządzeń wymagających aktualizacji, historię aktualizacji oraz najnowsze wersje systemu (dla RouterOS 7.x i 6.x). + +2. Zarządzanie urządzeniami: +Dodawaj i edytuj urządzenia, a także sprawdzaj ich aktualizacje i logi. + +3. Changelog: +Przeglądaj changelogi z podziałem na kanały wydania (stable, rc, beta) oraz serie wersji (7.x i 6.x). Możesz wybrać konkretny changelog z listy lub wyświetlić najnowszy wpis. + +4. Aktualizacja changelogów: +Użyj przycisku "Aktualizuj changelogi" na stronie changelogów, aby ręcznie pobrać wszystkie nowe wpisy (operacja usunie stare wpisy z bazy). + +## Wkład i rozwój + +Zapraszam do zgłaszania poprawek i propozycji rozwoju. Aby wnieść swój wkład: + +1. Forkuj repozytorium. +2. Wprowadź zmiany. +3. Otwórz pull request. + +## Licencja +Projekt jest dostępny na licencji MIT. \ No newline at end of file diff --git a/app.py b/app.py index 410b684..98caffb 100644 --- a/app.py +++ b/app.py @@ -5,7 +5,6 @@ from flask import Flask, render_template, request, redirect, url_for, flash, ses from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager, UserMixin, login_user, logout_user, current_user, login_required from werkzeug.security import generate_password_hash, check_password_hash -from datetime import datetime from apscheduler.schedulers.background import BackgroundScheduler import librouteros import threading @@ -16,7 +15,17 @@ from email.mime.text import MIMEText from flask import current_app as app from flask import render_template import atexit -from datetime import timedelta +from datetime import timedelta, datetime +import requests +from bs4 import BeautifulSoup +from flask import render_template, flash +import logging +import re +try: + from dateutil import parser as date_parser +except ImportError: + date_parser = None + # Konfiguracja aplikacji app = Flask(__name__) @@ -98,6 +107,14 @@ class Anomaly(db.Model): resolved = db.Column(db.Boolean, default=False) device = db.relationship('Device', backref='anomalies') +class ChangelogEntry(db.Model): + id = db.Column(db.Integer, primary_key=True) + version = db.Column(db.String(50), nullable=False) + details = db.Column(db.Text, nullable=False) + category = db.Column(db.String(10), nullable=False) # "6.x" or "7.x" + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + release_type = db.Column(db.String(10), nullable=False, default="stable") + # Inicjalizacja bazy (utworzyć bazę przy pierwszym uruchomieniu) with app.app_context(): db.create_all() @@ -272,6 +289,11 @@ def get_email_template(subject, message): """ +def clean_old_changelogs(): + with app.app_context(): + cutoff_time = datetime.utcnow() - timedelta(days=7) + db.session.query(ChangelogEntry).filter(ChangelogEntry.timestamp < cutoff_time).delete() + db.session.commit() def check_all_devices(): with app.app_context(): @@ -318,6 +340,119 @@ def clean_old_logs(): Log.query.filter(Log.user_id == setting.user_id, Log.timestamp < cutoff).delete() db.session.commit() +def parse_release_date(text): + """ + Próbuje wyłuskać datę z tekstu. + Najpierw używa regex, a w razie niepowodzenia – dateutil (jeśli dostępny). + """ + date_match = re.search(r"\((\d{4})-([A-Za-z]{3})-(\d{2})", text) + if date_match: + try: + return datetime.strptime(f"{date_match.group(1)}-{date_match.group(2)}-{date_match.group(3)}", "%Y-%b-%d") + except Exception as e: + logging.error(f"Błąd parsowania daty przy użyciu strptime: {e}") + if date_parser: + try: + return date_parser.parse(text, fuzzy=True) + except Exception as e: + logging.error(f"Błąd parsowania daty przy użyciu dateutil: {e}") + return None + +def get_release_type(version_text): + """ + Określa typ wydania na podstawie numeru wersji: + - Jeśli w tekście występuje "beta" – zwraca "beta" + - Jeśli w tekście występuje "rc" – zwraca "rc" + - W przeciwnym wypadku – "stable" + """ + lower = version_text.lower() + if "beta" in lower: + return "beta" + elif "rc" in lower: + return "rc" + else: + return "stable" + +def fetch_changelogs(force=False): + changelog_url = "https://mikrotik.com/download/changelogs" + current_date = datetime.utcnow() + + try: + logging.info(f"Pobieranie changelogów z {changelog_url}...") + response = requests.get(changelog_url, timeout=10) + response.raise_for_status() + soup = BeautifulSoup(response.text, "html.parser") + changelog_sections = soup.find_all("li", class_="accordion-navigation") + logging.info(f"Znaleziono {len(changelog_sections)} sekcji changelogów.") + + new_entries = 0 + for section in changelog_sections: + a_tag = section.find("a") + if not a_tag: + continue + + raw_text = a_tag.get_text(strip=True) + match = re.match(r"([0-9.]+[a-zA-Z0-9]*)", raw_text) + version_text = match.group(1) if match else raw_text + + # Pomijamy wersje, które nie zaczynają się od "6." lub "7." + if not (version_text.startswith("6.") or version_text.startswith("7.")): + logging.info(f"Pomijam wersję {version_text} – nie jest 6.x ani 7.x") + continue + + details_div = section.find("div", class_="content") + changelog_file_url = details_div.get("data-url") if details_div else None + + if not changelog_file_url: + logging.warning(f"Brak URL changeloga dla wersji {version_text}") + continue + + try: + changelog_response = requests.get(changelog_file_url, timeout=10) + changelog_response.raise_for_status() + changelog_lines = changelog_response.text.splitlines() + + if not changelog_lines: + logging.warning(f"Pusty changelog dla wersji {version_text}") + continue + + first_line = changelog_lines[0].strip() + release_date_dt = parse_release_date(first_line) + if release_date_dt is None: + logging.warning(f"Nie udało się wyłuskać daty dla wersji {version_text}, pomijam ten changelog") + continue # Pomijamy wpis bez daty + + changelog_text = "\n".join(changelog_lines).strip() + except Exception as e: + logging.error(f"Błąd pobierania changeloga z {changelog_file_url}: {e}") + continue + + # Filtrowanie: dla 7.x pomijamy wersje starsze niż 1 rok, dla 6.x starsze niż 2 lata + if version_text.startswith("7.") and release_date_dt < (current_date - timedelta(days=365)): + logging.info(f"Pomijam wersję {version_text} - starsza niż 1 rok") + continue + if version_text.startswith("6.") and release_date_dt < (current_date - timedelta(days=730)): + logging.info(f"Pomijam wersję {version_text} - starsza niż 2 lata") + continue + + # Określenie typu wydania (stable, rc, beta) + release_type = get_release_type(version_text) + + new_entry = ChangelogEntry( + version=version_text, + details=changelog_text, + category="6.x" if version_text.startswith("6.") else "7.x", + timestamp=release_date_dt, + release_type=release_type + ) + db.session.add(new_entry) + new_entries += 1 + + db.session.commit() + logging.info(f"Nowe wpisy dodane: {new_entries}") + except Exception as e: + logging.error(f"Błąd podczas pobierania changelogów: {e}") + def detect_anomalies(): with app.app_context(): # Ustal okres analizy, np. ostatnie 24 godziny @@ -363,12 +498,26 @@ with app.app_context(): max_instances=1 ) scheduler.add_job( - func=detect_anomalies, + func=detect_anomalies, trigger="interval", minutes=60, id="detect_anomalies", max_instances=1 ) + scheduler.add_job( + func=clean_old_changelogs, + trigger="interval", + days=1, + id="clean_changelogs", + max_instances=1 + ) + scheduler.add_job( + func=lambda: fetch_changelogs(force=False), + trigger="interval", + days=1, + id="daily_changelog_fetch", + max_instances=1 + ) app.logger.debug(f"Scheduler initialized with interval: {interval} seconds") @@ -376,6 +525,13 @@ scheduler.start() # ROUTY APLIKACJI +@app.template_filter('format_version') +def format_version(value): + import re + # Zamieniamy ciąg, który składa się z: liczba.część_1 + czterocyfrowy rok, na samą część_1. + # Przykładowo: "7.182025" => "7.18" + return re.sub(r"(\d+\.\d+)\d{4}", r"\1", value) + @app.route('/') def index(): if current_user.is_authenticated: @@ -389,11 +545,24 @@ def dashboard(): pending_updates_count = Device.query.filter_by(update_required=True).count() logs_count = Log.query.count() users_count = User.query.count() + anomalies_count = Anomaly.query.filter_by(resolved=False).count() + update_history_count = UpdateHistory.query.count() + recent_logs = Log.query.order_by(Log.timestamp.desc()).limit(5).all() + + # Pobieramy najnowsze wersje stabilne dla 7.x i 6.x + latest_version_7 = ChangelogEntry.query.filter_by(category="7.x", release_type="stable").order_by(ChangelogEntry.timestamp.desc()).first() + latest_version_6 = ChangelogEntry.query.filter_by(category="6.x", release_type="stable").order_by(ChangelogEntry.timestamp.desc()).first() + return render_template('dashboard.html', devices_count=devices_count, pending_updates_count=pending_updates_count, logs_count=logs_count, - users_count=users_count) + users_count=users_count, + anomalies_count=anomalies_count, + update_history_count=update_history_count, + recent_logs=recent_logs, + latest_version_7=latest_version_7, + latest_version_6=latest_version_6) # Rejestracja @app.route('/register', methods=['GET', 'POST']) @@ -679,9 +848,9 @@ def test_email(): flash("Testowy e-mail wysłany.") return redirect(url_for('settings')) -@app.route('/reset_password', methods=['GET', 'POST']) +@app.route('/change_password', methods=['GET', 'POST']) @login_required -def reset_password(): +def change_password(): if request.method == 'POST': old_password = request.form.get('old_password') new_password = request.form.get('new_password') @@ -696,7 +865,7 @@ def reset_password(): db.session.commit() flash("Hasło zostało zresetowane.") return redirect(url_for('reset_password')) - return render_template('reset_password.html') + return render_template('change_password.html') @app.route('/logs/clean', methods=['POST']) @login_required @@ -752,6 +921,43 @@ def update_selected_devices(): flash("Wybrane urządzenia zostały zaktualizowane.") return redirect(url_for('devices')) +@app.route('/routeros_changelog') +@login_required +def routeros_changelog(): + channel = request.args.get('channel', 'stable') # "stable", "rc" lub "beta" + series = request.args.get('series', '7.x') # "7.x" lub "6.x" + selected_version = request.args.get('version') + + # Pobieramy wszystkie wpisy dla danego kanału i serii, posortowane malejąco wg daty + entries = ChangelogEntry.query.filter_by(release_type=channel, category=series).order_by(ChangelogEntry.timestamp.desc()).all() + + if selected_version: + selected_entry = ChangelogEntry.query.filter_by(version=selected_version, release_type=channel, category=series).first() + else: + selected_entry = entries[0] if entries else None + + return render_template( + 'routeros_changelog_tabs.html', + channel=channel, + series=series, + entries=entries, + selected_entry=selected_entry + ) + +@app.route('/force_fetch_changelogs') +@login_required +def force_fetch_changelogs(): + with app.app_context(): + # Usuwamy wszystkie stare wpisy + db.session.query(ChangelogEntry).delete() + db.session.commit() + + # Pobieramy changelogi od nowa + fetch_changelogs(force=True) + + flash("Changelog został całkowicie odświeżony.", "success") + return redirect(url_for('routeros_changelog')) + # Zamknięcie harmonogramu przy zatrzymaniu aplikacji atexit.register(lambda: scheduler.shutdown()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5190415 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: routeros_update + ports: + - "5582:5582" + environment: + - FLASK_ENV=production + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt index c4a8411..e187a2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ Flask-Login APScheduler librouteros requests -waitress \ No newline at end of file +waitress +bs4 \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..806086e --- /dev/null +++ b/start.sh @@ -0,0 +1,21 @@ +#!/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!" diff --git a/templates/base.html b/templates/base.html index 8a83367..bd902b1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,65 +6,289 @@ + + {% block extra_head %}{% endblock %} - - - - -
- -
+ + +
+
Dane urządzenia @@ -55,10 +67,9 @@
- -
+
-
+
Informacje o systemie
@@ -85,19 +96,19 @@
- -
-
-

- -

-
-
-
{{ device.last_log or 'Brak logów' }}
+ +
+
+ Logi urządzenia +
+
+ {% if device.last_log %} +
+ {{ device.last_log }}
-
+ {% else %} +

Brak logów

+ {% endif %}
diff --git a/templates/routeros_changelog_tabs.html b/templates/routeros_changelog_tabs.html new file mode 100644 index 0000000..f2ab3b8 --- /dev/null +++ b/templates/routeros_changelog_tabs.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}Changelog RouterOS{% endblock %} +{% block extra_head %} + + + + +{% endblock %} +{% block content %} +
+ +
+

Changelog RouterOS

+ + Aktualizuj changelogi + +
+ + + + + + + + + {% if selected_entry %} +
+

+ {{ selected_entry.version | format_version }} + ({{ selected_entry.timestamp.strftime('%Y-%b-%d') }}) +

+
{{ selected_entry.details }}
+
+ {% else %} +

Brak wpisów dla wybranych ustawień.

+ {% endif %} + + + {% if entries|length > 1 %} + + + +
+ + +
+ + {% endif %} + + +
+{% endblock %}