Add project files: source code, Docker setup, docs and config templates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+398
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Flask REST-API und statische Dateien.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from flask import Flask, jsonify, send_from_directory, abort, request as flask_request
|
||||
|
||||
import database as db
|
||||
|
||||
app = Flask(__name__, static_folder='web', static_url_path='/static')
|
||||
|
||||
# Wird vom main.py gesetzt
|
||||
_monitor = None
|
||||
|
||||
|
||||
def set_monitor(monitor):
|
||||
global _monitor
|
||||
_monitor = monitor
|
||||
|
||||
|
||||
# ── Statische Dateien ────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory('web', 'index.html')
|
||||
|
||||
|
||||
# ── API Endpunkte ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/status')
|
||||
def api_status():
|
||||
"""Aktueller Systemstatus."""
|
||||
mon_status = _monitor.status() if _monitor else {}
|
||||
traffic = db.query_current_traffic()
|
||||
fritz = db.query_fritz_latest()
|
||||
|
||||
# Heutige Ausfälle
|
||||
today_start = int(datetime.now(timezone.utc).replace(hour=0, minute=0, second=0).timestamp())
|
||||
outages_today = db.query_outages(since_ts=today_start, limit=500)
|
||||
total_outage_s = sum(o['duration_s'] or 0 for o in outages_today if o['end_ts'])
|
||||
current_outage = next((o for o in outages_today if not o['end_ts']), None)
|
||||
|
||||
devices = db.query_devices()
|
||||
active_count = sum(1 for d in devices if d['is_active'])
|
||||
|
||||
return jsonify({
|
||||
'internet_up': mon_status.get('internet_up', True),
|
||||
'bps_up': traffic.get('bps_sent', 0),
|
||||
'bps_down': traffic.get('bps_recv', 0),
|
||||
'active_devices': active_count,
|
||||
'total_devices': len(devices),
|
||||
'outages_today': len([o for o in outages_today if o['end_ts']]),
|
||||
'total_outage_seconds_today': total_outage_s,
|
||||
'current_outage_since': current_outage['start_ts'] if current_outage else None,
|
||||
'fritz': {
|
||||
'available': fritz is not None,
|
||||
'is_connected': fritz['is_connected'] if fritz else None,
|
||||
'external_ip': fritz['external_ip'] if fritz else None,
|
||||
'uptime_s': fritz['uptime_s'] if fritz else None,
|
||||
'max_up_bps': fritz['max_up_bps'] if fritz else None,
|
||||
'max_down_bps': fritz['max_down_bps'] if fritz else None,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/traffic')
|
||||
def api_traffic():
|
||||
"""Traffic-Zeitreihe. ?range=1h|6h|24h|7d|30d"""
|
||||
from flask import request
|
||||
range_map = {
|
||||
'1h': (3600, 60),
|
||||
'6h': (21600, 300),
|
||||
'24h': (86400, 900),
|
||||
'7d': (604800, 3600),
|
||||
'30d': (2592000, 14400),
|
||||
}
|
||||
r = request.args.get('range', '1h')
|
||||
seconds, bucket = range_map.get(r, (3600, 60))
|
||||
since = int(time.time()) - seconds
|
||||
|
||||
rows = db.query_traffic(since_ts=since, bucket_size=bucket)
|
||||
labels = [_fmt_ts(row['bucket'], r) for row in rows]
|
||||
upload = [round(row['bps_sent'] / 1000, 2) for row in rows] # kbit/s
|
||||
download = [round(row['bps_recv'] / 1000, 2) for row in rows]
|
||||
|
||||
return jsonify({'labels': labels, 'upload': upload, 'download': download})
|
||||
|
||||
|
||||
@app.route('/api/outages')
|
||||
def api_outages():
|
||||
"""Ausfalls-Historie."""
|
||||
from flask import request
|
||||
limit = int(request.args.get('limit', 100))
|
||||
since = request.args.get('since')
|
||||
since_ts = int(since) if since else None
|
||||
|
||||
rows = db.query_outages(since_ts=since_ts, limit=limit)
|
||||
result = []
|
||||
for r in rows:
|
||||
duration = r['duration_s']
|
||||
result.append({
|
||||
'id': r['id'],
|
||||
'start_ts': r['start_ts'],
|
||||
'end_ts': r['end_ts'],
|
||||
'duration_s': duration,
|
||||
'duration_human': _human_duration(duration) if duration else 'Läuft noch',
|
||||
'start_human': _fmt_dt(r['start_ts']),
|
||||
'end_human': _fmt_dt(r['end_ts']) if r['end_ts'] else 'Läuft noch',
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route('/api/devices')
|
||||
def api_devices():
|
||||
"""Alle Geräte mit Status und Port-Anzahl."""
|
||||
devices = db.query_devices()
|
||||
result = []
|
||||
for d in devices:
|
||||
result.append({
|
||||
'id': d['id'],
|
||||
'mac': d['mac'],
|
||||
'ip': d['ip'] or '',
|
||||
'name': d['fritz_name'] or d['hostname'] or d['ip'] or d['mac'],
|
||||
'hostname': d['hostname'] or '',
|
||||
'fritz_name': d['fritz_name'] or '',
|
||||
'vendor': d['vendor'] or '',
|
||||
'iface_type': d['iface_type'] or '',
|
||||
'is_active': bool(d['is_active']),
|
||||
'first_seen': d['first_seen'],
|
||||
'last_seen': d['last_seen'],
|
||||
'last_seen_human': _fmt_dt(d['last_seen']),
|
||||
'port_count': d['port_count'],
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route('/api/devices/<int:device_id>/ports')
|
||||
def api_device_ports(device_id):
|
||||
"""Offene Ports eines Geräts."""
|
||||
ports = db.query_device_ports(device_id)
|
||||
return jsonify(ports)
|
||||
|
||||
|
||||
@app.route('/api/packets')
|
||||
def api_packets():
|
||||
"""Live-Paketmitschnitt. ?since=UNIX_TS&limit=200&filter=text"""
|
||||
from flask import request
|
||||
since = float(request.args.get('since', time.time() - 30))
|
||||
limit = int(request.args.get('limit', 200))
|
||||
filter_text = request.args.get('filter', '').strip() or None
|
||||
rows = db.query_packets(since_ts=since, limit=limit, filter_text=filter_text)
|
||||
# Neueste zuletzt (für Append im Frontend)
|
||||
rows.reverse()
|
||||
return jsonify(rows)
|
||||
|
||||
|
||||
@app.route('/api/ping_log')
|
||||
def api_ping_log():
|
||||
"""Letzter Ping-Log – zeigt auch kurze Ausfälle. ?minutes=60"""
|
||||
from flask import request
|
||||
minutes = int(request.args.get('minutes', 60))
|
||||
since = int(time.time()) - minutes * 60
|
||||
rows = db.query_ping_log(since_ts=since, limit=2000)
|
||||
stats = db.query_ping_stats(since_ts=since)
|
||||
return jsonify({'log': rows, 'stats': stats})
|
||||
|
||||
|
||||
_ENV_PATH = '/app/.env'
|
||||
|
||||
# Whitelist aller erlaubten .env-Keys (verhindert willkürliche Datei-Injection)
|
||||
_ALLOWED_KEYS = {
|
||||
'FRITZ_HOST', 'FRITZ_USER', 'FRITZ_PASSWORD',
|
||||
'MONITOR_INTERFACE', 'NETWORK_RANGE',
|
||||
'TRAFFIC_INTERVAL', 'OUTAGE_CHECK_INTERVAL', 'DEVICE_SCAN_INTERVAL',
|
||||
'PORT_SCAN_INTERVAL', 'FRITZ_POLL_INTERVAL',
|
||||
'PING_TARGET_INTERVAL', 'DEVICE_PING_INTERVAL', 'PING_TARGETS',
|
||||
'DB_PATH', 'WEB_HOST', 'WEB_PORT', 'LOG_LEVEL',
|
||||
'SWITCH_ENABLED', 'SWITCH_HOST', 'SWITCH_PASSWORD',
|
||||
'SWITCH_GATEWAY_PORT', 'SWITCH_INTERVAL',
|
||||
'CAPTURE_ENABLED', 'CAPTURE_MAX_ROWS',
|
||||
'CAPTURE_FILTER_ICMP', 'CAPTURE_FILTER_ARP',
|
||||
'CAPTURE_FILTER_ZABBIX', 'CAPTURE_FILTER_RUSTDESK', 'CAPTURE_FILTER_EXTRA',
|
||||
'OUTAGE_THRESHOLD',
|
||||
# Benachrichtigungen
|
||||
'NOTIFY_ON_DOWN', 'NOTIFY_ON_UP', 'NOTIFY_COOLDOWN',
|
||||
'PUSHOVER_ENABLED', 'PUSHOVER_APP_TOKEN', 'PUSHOVER_USER_KEY',
|
||||
'NCTALK_ENABLED', 'NCTALK_URL', 'NCTALK_USER', 'NCTALK_PASSWORD', 'NCTALK_ROOM',
|
||||
}
|
||||
|
||||
|
||||
def _read_env_file() -> list[str]:
|
||||
try:
|
||||
with open(_ENV_PATH, 'r') as f:
|
||||
return f.readlines()
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
|
||||
def _parse_env(lines: list[str]) -> dict:
|
||||
result = {}
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith('#') and '=' in stripped:
|
||||
key, _, val = stripped.partition('=')
|
||||
result[key.strip()] = val.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _write_env(updates: dict) -> None:
|
||||
"""Schreibt aktualisierte Werte in .env, behält Kommentare und Struktur."""
|
||||
lines = _read_env_file()
|
||||
updated_keys = set()
|
||||
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith('#') and '=' in stripped:
|
||||
key = stripped.split('=', 1)[0].strip()
|
||||
if key in updates and key in _ALLOWED_KEYS:
|
||||
new_lines.append(f'{key}={updates[key]}\n')
|
||||
updated_keys.add(key)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
# Neue Keys anhängen die noch nicht in der Datei waren
|
||||
for key, val in updates.items():
|
||||
if key in _ALLOWED_KEYS and key not in updated_keys:
|
||||
new_lines.append(f'{key}={val}\n')
|
||||
|
||||
with open(_ENV_PATH, 'w') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
|
||||
@app.route('/api/config', methods=['GET'])
|
||||
def api_config_get():
|
||||
"""Aktuelle .env-Konfiguration auslesen."""
|
||||
lines = _read_env_file()
|
||||
config = _parse_env(lines)
|
||||
return jsonify({k: v for k, v in config.items() if k in _ALLOWED_KEYS})
|
||||
|
||||
|
||||
@app.route('/api/config', methods=['POST'])
|
||||
def api_config_post():
|
||||
"""Konfiguration speichern."""
|
||||
data = flask_request.get_json(force=True)
|
||||
if not data or not isinstance(data, dict):
|
||||
return jsonify({'error': 'Ungültige Daten'}), 400
|
||||
|
||||
# Nur erlaubte Keys
|
||||
updates = {k: str(v) for k, v in data.items() if k in _ALLOWED_KEYS}
|
||||
if not updates:
|
||||
return jsonify({'error': 'Keine gültigen Keys'}), 400
|
||||
|
||||
_write_env(updates)
|
||||
return jsonify({'ok': True, 'saved': list(updates.keys())})
|
||||
|
||||
|
||||
@app.route('/api/restart', methods=['POST'])
|
||||
def api_restart():
|
||||
"""Container neu starten (Docker restart: unless-stopped)."""
|
||||
def _do_restart():
|
||||
time.sleep(0.8)
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
threading.Thread(target=_do_restart, daemon=True).start()
|
||||
return jsonify({'ok': True, 'message': 'Neustart eingeleitet…'})
|
||||
|
||||
|
||||
@app.route('/api/notify_test', methods=['POST'])
|
||||
def api_notify_test():
|
||||
"""Sendet Test-Benachrichtigung über alle aktivierten Kanäle."""
|
||||
import notify
|
||||
results = notify.test_notification()
|
||||
return jsonify(results)
|
||||
|
||||
|
||||
@app.route('/api/ping_targets_live')
|
||||
def api_ping_targets_live():
|
||||
""".env PING_TARGETS: Live-Log der letzten N Sekunden."""
|
||||
from flask import request
|
||||
seconds = int(request.args.get('seconds', 120))
|
||||
since = int(time.time()) - seconds
|
||||
rows = db.query_ping_log(since_ts=since, limit=2000)
|
||||
# Neueste zuletzt für Live-Append
|
||||
rows.reverse()
|
||||
targets = [t.strip() for t in os.getenv('PING_TARGETS', '').split(',') if t.strip()]
|
||||
stats = db.query_ping_stats(since_ts=int(time.time()) - 300) # letzte 5min
|
||||
return jsonify({
|
||||
'targets': targets,
|
||||
'log': rows,
|
||||
'stats': {s['target']: s for s in stats},
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/device_pings')
|
||||
def api_device_pings():
|
||||
"""Alle Netzwerk-Geräte (nicht in PING_TARGETS): letztes Ping-Ergebnis + Statistik."""
|
||||
from flask import request
|
||||
minutes = int(request.args.get('minutes', 30))
|
||||
since = int(time.time()) - minutes * 60
|
||||
latest = db.query_device_pings_latest()
|
||||
stats = db.query_device_ping_stats(since_ts=since)
|
||||
stats_map = {s['ip']: s for s in stats}
|
||||
result = []
|
||||
for row in latest:
|
||||
s = stats_map.get(row['ip'], {})
|
||||
result.append({
|
||||
'ip': row['ip'],
|
||||
'mac': row['mac'],
|
||||
'success': bool(row['success']),
|
||||
'latency_ms': row['latency_ms'],
|
||||
'ts': row['ts'],
|
||||
'ts_human': _fmt_dt(row['ts']),
|
||||
'total': s.get('total', 0),
|
||||
'ok': s.get('ok', 0),
|
||||
'avg_ms': s.get('avg_ms'),
|
||||
'min_ms': s.get('min_ms'),
|
||||
'max_ms': s.get('max_ms'),
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route('/api/switch')
|
||||
def api_switch():
|
||||
"""Switch Port-Status und Ereignisse."""
|
||||
from flask import request
|
||||
ports = db.query_switch_latest()
|
||||
hours = int(request.args.get('hours', 24))
|
||||
since = int(time.time()) - hours * 3600
|
||||
events = db.query_switch_events(since_ts=since, limit=200)
|
||||
|
||||
return jsonify({
|
||||
'enabled': bool(os.getenv('SWITCH_ENABLED', 'false').lower() == 'true'),
|
||||
'host': os.getenv('SWITCH_HOST', ''),
|
||||
'gateway_port': int(os.getenv('SWITCH_GATEWAY_PORT', '') or 1),
|
||||
'ports': [dict(p) for p in ports],
|
||||
'events': [
|
||||
{**dict(e), 'ts_human': _fmt_dt(e['ts'])}
|
||||
for e in events
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/fritz')
|
||||
def api_fritz():
|
||||
"""FritzBox-Verlauf, aktueller Status und Geräteinformationen."""
|
||||
from flask import request
|
||||
hours = int(request.args.get('hours', 24))
|
||||
since = int(time.time()) - hours * 3600
|
||||
history = db.query_fritz_history(since_ts=since, bucket_size=300)
|
||||
latest = db.query_fritz_latest()
|
||||
|
||||
# Live-Infos direkt vom Gerät abrufen
|
||||
full_info = None
|
||||
if _monitor and hasattr(_monitor, 'fritz') and _monitor.fritz:
|
||||
full_info = _monitor.fritz.get_full_info()
|
||||
|
||||
return jsonify({
|
||||
'latest': dict(latest) if latest else None,
|
||||
'history': history,
|
||||
'info': full_info,
|
||||
'host': os.getenv('FRITZ_HOST', ''),
|
||||
})
|
||||
|
||||
|
||||
# ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
||||
|
||||
def _fmt_ts(ts, range_key):
|
||||
"""Formatiert Timestamp je nach Zeitbereich."""
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
if range_key in ('1h', '6h'):
|
||||
return dt.strftime('%H:%M')
|
||||
elif range_key == '24h':
|
||||
return dt.strftime('%H:%M')
|
||||
else:
|
||||
return dt.strftime('%d.%m %H:%M')
|
||||
|
||||
|
||||
def _fmt_dt(ts):
|
||||
if not ts:
|
||||
return ''
|
||||
return datetime.fromtimestamp(ts).strftime('%d.%m.%Y %H:%M:%S')
|
||||
|
||||
|
||||
def _human_duration(seconds):
|
||||
if not seconds:
|
||||
return '< 1s'
|
||||
parts = []
|
||||
h = seconds // 3600
|
||||
m = (seconds % 3600) // 60
|
||||
s = seconds % 60
|
||||
if h: parts.append(f"{h}h")
|
||||
if m: parts.append(f"{m}min")
|
||||
if s or not parts: parts.append(f"{s}s")
|
||||
return ' '.join(parts)
|
||||
Reference in New Issue
Block a user