Initial commit

This commit is contained in:
root
2025-12-03 16:46:11 +00:00
commit 1612d10bb1
25 changed files with 145983 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Geben Sie hier Ihre Anmeldedaten für den Admin-Bereich ein
# Ändern Sie diese Werte unbedingt in etwas Sicheres!
ADMIN_USERNAME=admin
ADMIN_PASSWORD=password
# Ändern Sie diesen Schlüssel in eine lange, zufällige Zeichenfolge.
# Sie können zum Beispiel 'openssl rand -hex 32' in Ihrem Terminal ausführen, um einen zu generieren.
SECRET_KEY=super_secret_key_change_me

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Environment variables
.env
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/

21
app/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM python:3.9-slim
# Installation der Bildverarbeitungs-Abhängigkeiten (Wichtig für Pillow)
RUN apt-get update && apt-get install -y \
libjpeg-dev \
zlib1g-dev \
libfreetype6-dev \
liblcms2-dev \
libwebp-dev \
tcl-dev \
tk-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

843
app/app.py Normal file
View File

@@ -0,0 +1,843 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, session, flash
from urllib.parse import urljoin
import requests
import urllib3
import json
import sys
import time
import os
import uuid
from werkzeug.utils import secure_filename
from PIL import Image
from functools import wraps
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import func
# Disable SSL warnings for local services
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app = Flask(__name__)
UPLOAD_FOLDER = '/app/static/uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////data/dashboard.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.secret_key = os.environ.get('SECRET_KEY', 'a-secure-default-secret-key-for-development-only')
db = SQLAlchemy(app)
# --- Caching for Status Checks ---
URL_CACHE = {}
CACHE_DURATION = 300 # 5 minutes
# --- Custom Filters ---
@app.template_filter('json_loads')
def json_loads_filter(s):
if not s: return {}
try:
return json.loads(s)
except Exception:
return {}
# --- Database Models ---
class Setting(db.Model):
key = db.Column(db.String(50), primary_key=True)
value = db.Column(db.String(255))
class Page(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
icon = db.Column(db.String(255))
order = db.Column(db.Integer, default=0)
groups = db.relationship('Group', backref='page', lazy=True, cascade="all, delete-orphan", order_by="Group.order")
widgets = db.relationship('Widget', backref='page', lazy=True, cascade="all, delete-orphan", order_by="Widget.order")
class Group(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
icon = db.Column(db.String(255))
order = db.Column(db.Integer, default=0)
page_id = db.Column(db.Integer, db.ForeignKey('page.id'), nullable=False)
links = db.relationship('Link', backref='group', lazy=True, cascade="all, delete-orphan", order_by="Link.order")
class Link(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
url = db.Column(db.String(255), nullable=False)
icon = db.Column(db.String(255))
subtitle = db.Column(db.String(255))
target = db.Column(db.String(20), default='_self')
order = db.Column(db.Integer, default=0)
group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=False)
class Widget(db.Model):
id = db.Column(db.Integer, primary_key=True)
type = db.Column(db.String(50), nullable=False) # e.g., 'pihole_status', 'openmeteo_weather', 'iframe_widget'
config = db.Column(db.Text, nullable=True) # JSON-formatted string
order = db.Column(db.Integer, default=0)
page_id = db.Column(db.Integer, db.ForeignKey('page.id'), nullable=False) # Changed from group_id to page_id
def migrate_db():
from sqlalchemy import text
with app.app_context():
db.create_all()
try:
with db.engine.connect() as conn:
# Check Page order
try:
conn.execute(text("SELECT \"order\" FROM page LIMIT 1"))
except Exception:
print("Migrating DB: Adding order column to Page")
conn.execute(text("ALTER TABLE page ADD COLUMN \"order\" INTEGER DEFAULT 0"))
conn.commit()
# Check Group order
try:
conn.execute(text("SELECT \"order\" FROM \"group\" LIMIT 1"))
except Exception:
print("Migrating DB: Adding order column to Group")
conn.execute(text("ALTER TABLE \"group\" ADD COLUMN \"order\" INTEGER DEFAULT 0"))
conn.commit()
# Check Link order
try:
conn.execute(text("SELECT \"order\" FROM link LIMIT 1"))
except Exception:
print("Migrating DB: Adding order column to Link")
conn.execute(text("ALTER TABLE link ADD COLUMN \"order\" INTEGER DEFAULT 0"))
conn.commit()
# Check Widget page_id migration and Cleanup group_id
try:
# Check if group_id still exists by trying to select it
conn.execute(text("SELECT group_id FROM widget LIMIT 1"))
print("Migrating DB: Removing group_id from Widget (Recreating Table)")
# 1. Create new table
conn.execute(text("""
CREATE TABLE widget_new (
id INTEGER NOT NULL,
type VARCHAR(50) NOT NULL,
config TEXT,
"order" INTEGER,
page_id INTEGER NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(page_id) REFERENCES page (id)
)
"""))
# 2. Copy data
# If page_id was already populated by previous migration, use it.
# If not (failed partial migration), try to derive from group.
# We try to copy page_id first.
try:
conn.execute(text("INSERT INTO widget_new (id, type, config, \"order\", page_id) SELECT id, type, config, \"order\", page_id FROM widget WHERE page_id IS NOT NULL"))
except Exception:
# Fallback: Calculate page_id from group_id link if page_id was missing/null
conn.execute(text("INSERT INTO widget_new (id, type, config, \"order\", page_id) SELECT w.id, w.type, w.config, w.\"order\", g.page_id FROM widget w JOIN \"group\" g ON w.group_id = g.id"))
# 3. Drop old table
conn.execute(text("DROP TABLE widget"))
# 4. Rename new table
conn.execute(text("ALTER TABLE widget_new RENAME TO widget"))
conn.commit()
print("Widget table migrated and cleaned successfully.")
except Exception as e:
# If group_id doesn't exist, we assume table is already clean or check logic is different
# But we should ensure page_id exists
try:
conn.execute(text("SELECT page_id FROM widget LIMIT 1"))
except:
print(f"Deep migration error: {e}")
# Check Widget order
try:
conn.execute(text("SELECT \"order\" FROM widget LIMIT 1"))
except Exception:
print("Migrating DB: Adding order column to Widget")
conn.execute(text("ALTER TABLE widget ADD COLUMN \"order\" INTEGER DEFAULT 0"))
conn.commit()
except Exception as e:
print(f"Migration general warning: {e}")
migrate_db()
# --- Authentication ---
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('logged_in'):
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if username == os.environ.get('ADMIN_USERNAME') and password == os.environ.get('ADMIN_PASSWORD'):
session['logged_in'] = True
flash('Login erfolgreich!', 'success')
next_url = request.args.get('next')
return redirect(next_url or url_for('admin'))
else:
flash('Falscher Benutzername oder Passwort.', 'danger')
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('logged_in', None)
flash('Du wurdest ausgeloggt.', 'info')
return redirect(url_for('index'))
# --- Helper & Image Processing ---
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def save_and_scale_icon(request_file):
if request_file and request_file.filename != '' and allowed_file(request_file.filename):
try:
unique_filename = f"icon_{uuid.uuid4().hex}.png"
temp_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
img = Image.open(request_file.stream)
img = img.resize((48, 48), Image.Resampling.LANCZOS)
if img.mode != 'RGBA': img = img.convert('RGBA')
img.save(temp_path, 'PNG')
return unique_filename
except Exception as e:
print(f"Fehler bei der Bildverarbeitung: {e}")
return None
return None
def save_background_or_logo(request_file, prefix):
if request_file and request_file.filename != '' and allowed_file(request_file.filename):
try:
filename = secure_filename(f"{prefix}_{request_file.filename}")
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
request_file.save(filepath)
return filename
except Exception as e:
print(f"Fehler beim Speichern: {e}")
return None
# --- Database Helper ---
def get_all_data():
settings_query = Setting.query.all()
settings = {s.key: s.value for s in settings_query}
defaults = default_settings()
for key, value in defaults.items():
if key not in settings:
settings[key] = value
pages = Page.query.order_by(Page.order).all()
return {
"settings": settings,
"pages": pages
}
def default_settings():
return {
"title": "Mein Dashboard",
"bg_image": "",
"logo_image": "",
"header_color": "#363636",
"header_font_color": "#FFFFFF",
"header_inactive_font_color": "#C0C0C0",
"group_title_font_color": "#FFFFFF",
"columns": "is-one-quarter-desktop",
"theme_color": "#3273dc",
"overlay_opacity": "0.85"
}
# --- Routes ---
def hex_to_rgb(hex_color):
hex_color = hex_color.lstrip('#')
return ",".join(str(int(hex_color[i:i+2], 16)) for i in (0, 2, 4))
@app.route('/')
def index():
data = get_all_data()
if not data['pages']:
return render_template('empty.html')
active_page_index = request.args.get('page', 0, type=int)
if active_page_index >= len(data['pages']):
active_page_index = 0
active_page = data['pages'][active_page_index]
if 'group_bg_color' in data['settings']:
data['settings']['group_bg_color_rgb'] = hex_to_rgb(data['settings']['group_bg_color'])
return render_template('index.html', data=data, active_page=active_page, active_index=active_page_index)
@app.route('/admin')
@login_required
def admin():
data = get_all_data()
return render_template('admin.html', data=data)
@app.route('/admin/local_icons')
@login_required
def local_icons():
return jsonify([f for f in os.listdir(UPLOAD_FOLDER) if f.startswith('icon_') or f.startswith('upload_') or f.startswith('link_')])
@app.route('/admin/media_manager')
@login_required
def media_manager():
files = []
for f in os.listdir(UPLOAD_FOLDER):
filepath = os.path.join(UPLOAD_FOLDER, f)
try:
if os.path.isfile(filepath):
files.append({
'name': f,
'size': f"{os.path.getsize(filepath) / 1024:.2f} KB",
'type': 'Icon' if f.startswith('icon_') else 'Hintergrund' if f.startswith('bg_') else 'Logo' if f.startswith('logo_') else 'Unbekannt'
})
except Exception as e:
continue
return render_template('media.html', files=files)
def _update_setting(key, value):
setting = Setting.query.filter_by(key=key).first()
if setting:
setting.value = value
else:
db.session.add(Setting(key=key, value=value))
@app.route('/admin/update_settings', methods=['POST'])
@login_required
def update_settings():
for key, value in request.form.items():
_update_setting(key, value)
bg_filename = save_background_or_logo(request.files.get('bg_file'), 'bg')
if bg_filename:
_update_setting('bg_image', bg_filename)
logo_filename = save_background_or_logo(request.files.get('logo_file'), 'logo')
if logo_filename:
_update_setting('logo_image', logo_filename)
_update_setting('settings_configured', 'true')
db.session.commit()
flash('Einstellungen gespeichert!', 'success')
return redirect(url_for('admin', tab='settings'))
@app.route('/admin/delete_media/<string:filename>')
@login_required
def delete_media(filename):
if '..' in filename or os.path.sep in filename:
return "Ungültiger Dateiname", 400
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
# Check if this file was used as the background or logo
bg_setting = Setting.query.filter_by(key='bg_image', value=filename).first()
if bg_setting:
bg_setting.value = ""
logo_setting = Setting.query.filter_by(key='logo_image', value=filename).first()
if logo_setting:
logo_setting.value = ""
db.session.commit()
flash(f'Datei {filename} gelöscht.', 'info')
return redirect(url_for('media_manager'))
# --- CRUD Routes (Pages, Groups, Links, Widgets) ---
@app.route('/admin/add_page', methods=['POST'])
@login_required
def add_page():
name = request.form.get('name')
if name:
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
new_page = Page(name=name, icon=icon_val)
db.session.add(new_page)
db.session.commit()
return redirect(url_for('admin'))
@app.route('/admin/delete_page/<int:page_id>')
@login_required
def delete_page(page_id):
page = Page.query.get_or_404(page_id)
db.session.delete(page)
db.session.commit()
return redirect(url_for('admin'))
@app.route('/admin/add_group/<int:page_id>', methods=['POST'])
@login_required
def add_group(page_id):
page = Page.query.get_or_404(page_id)
name = request.form.get('name')
if name:
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
new_group = Group(name=name, icon=icon_val, page_id=page.id)
db.session.add(new_group)
db.session.commit()
return redirect(url_for('admin'))
@app.route('/admin/delete_group/<int:group_id>')
@login_required
def delete_group(group_id):
group = Group.query.get_or_404(group_id)
db.session.delete(group)
db.session.commit()
return redirect(url_for('admin'))
@app.route('/admin/add_link/<int:group_id>', methods=['POST'])
@login_required
def add_link(group_id):
group = Group.query.get_or_404(group_id)
name = request.form.get('name')
url = request.form.get('url')
if name and url:
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
new_link = Link(
name=name,
url=url,
icon=icon_val,
subtitle=request.form.get('subtitle'),
target=request.form.get('target', '_blank'),
group_id=group.id
)
db.session.add(new_link)
db.session.commit()
return redirect(url_for('admin'))
@app.route('/admin/delete_link/<int:link_id>')
@login_required
def delete_link(link_id):
link = Link.query.get_or_404(link_id)
db.session.delete(link)
db.session.commit()
return redirect(url_for('admin'))
@app.route('/admin/add_widget/<int:page_id>', methods=['POST'])
@login_required
def add_widget(page_id):
widget_type = request.form.get('type')
if not widget_type:
flash('Widget-Typ nicht angegeben.', 'danger')
return redirect(url_for('admin'))
config = {}
if widget_type == 'pihole_status':
config = {
'api_url': request.form.get('pihole_api_url'),
'api_key': request.form.get('pihole_api_key')
}
elif widget_type == 'openmeteo_weather':
config = {
'latitude': request.form.get('weather_lat'),
'longitude': request.form.get('weather_lon')
}
elif widget_type == 'iframe_widget':
config = {
'url': request.form.get('iframe_url'),
'height': request.form.get('iframe_height')
}
new_widget = Widget(
page_id=page_id,
type=widget_type,
config=json.dumps(config)
)
db.session.add(new_widget)
db.session.commit()
flash('Widget erfolgreich hinzugefügt!', 'success')
return redirect(url_for('admin'))
@app.route('/admin/edit_widget/<int:widget_id>', methods=['POST'])
@login_required
def edit_widget(widget_id):
widget = Widget.query.get_or_404(widget_id)
config = {}
if widget.type == 'pihole_status':
config = {
'api_url': request.form.get('pihole_api_url'),
'api_key': request.form.get('pihole_api_key')
}
elif widget.type == 'openmeteo_weather':
config = {
'latitude': request.form.get('weather_lat'),
'longitude': request.form.get('weather_lon')
}
elif widget.type == 'iframe_widget':
config = {
'url': request.form.get('iframe_url'),
'height': request.form.get('iframe_height')
}
widget.config = json.dumps(config)
db.session.commit()
flash('Widget erfolgreich aktualisiert!', 'success')
return redirect(url_for('admin'))
@app.route('/admin/delete_widget/<int:widget_id>')
@login_required
def delete_widget(widget_id):
widget = Widget.query.get_or_404(widget_id)
db.session.delete(widget)
db.session.commit()
flash('Widget gelöscht.', 'info')
return redirect(url_for('admin'))
@app.route('/admin/edit_link/<int:link_id>', methods=['POST'])
@login_required
def edit_link(link_id):
link = Link.query.get_or_404(link_id)
link.name = request.form.get('name')
link.url = request.form.get('url')
link.subtitle = request.form.get('subtitle')
link.target = request.form.get('target')
new_group_id = request.form.get('group_id')
if new_group_id and int(new_group_id) != link.group_id:
link.group_id = int(new_group_id)
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
new_icon_text = request.form.get('icon_text')
if new_icon_file:
link.icon = new_icon_file
elif new_icon_text:
link.icon = new_icon_text
db.session.commit()
return redirect(url_for('admin'))
@app.route('/admin/edit_page/<int:page_id>', methods=['POST'])
@login_required
def edit_page(page_id):
page = Page.query.get_or_404(page_id)
page.name = request.form.get('name')
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
new_icon_text = request.form.get('icon_text')
if new_icon_file:
page.icon = new_icon_file
elif new_icon_text or 'icon_text' in request.form:
page.icon = new_icon_text
db.session.commit()
flash('Seite erfolgreich aktualisiert!', 'success')
return redirect(url_for('admin'))
@app.route('/admin/edit_group/<int:group_id>', methods=['POST'])
@login_required
def edit_group(group_id):
group = Group.query.get_or_404(group_id)
group.name = request.form.get('name')
new_page_id = request.form.get('page_id')
if new_page_id and int(new_page_id) != group.page_id:
group.page_id = int(new_page_id)
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
new_icon_text = request.form.get('icon_text')
if new_icon_file:
group.icon = new_icon_file
elif new_icon_text or 'icon_text' in request.form:
group.icon = new_icon_text
db.session.commit()
flash('Gruppe erfolgreich aktualisiert!', 'success')
return redirect(url_for('admin'))
# --- Reordering ---
@app.route('/admin/reorder_pages', methods=['POST'])
@login_required
def reorder_pages():
order_data = request.json.get('order')
if not order_data: return jsonify({'status': 'error'}), 400
for index, page_id in enumerate(order_data):
page = Page.query.get(page_id)
if page: page.order = index
db.session.commit()
return jsonify({'status': 'success'})
@app.route('/admin/reorder_groups', methods=['POST'])
@login_required
def reorder_groups():
data = request.json
order_data = data.get('order')
page_id = data.get('page_id')
if not order_data: return jsonify({'status': 'error'}), 400
for index, group_id in enumerate(order_data):
group = Group.query.get(group_id)
if group:
group.order = index
if page_id:
group.page_id = int(page_id)
db.session.commit()
return jsonify({'status': 'success'})
@app.route('/admin/reorder_links', methods=['POST'])
@login_required
def reorder_links():
data = request.json
order_data = data.get('order')
group_id = data.get('group_id')
if not order_data: return jsonify({'status': 'error'}), 400
for index, link_id in enumerate(order_data):
link = Link.query.get(link_id)
if link:
link.order = index
if group_id:
link.group_id = int(group_id)
db.session.commit()
return jsonify({'status': 'success'})
# --- Utility Routes ---
@app.route('/check_url')
def check_url():
url = request.args.get('url')
if not url:
return jsonify({'status': 'offline', 'reason': 'No URL provided'}), 400
# Check Cache
current_time = time.time()
if url in URL_CACHE:
status, timestamp = URL_CACHE[url]
if current_time - timestamp < CACHE_DURATION:
return jsonify({'status': status, 'cached': True})
status = 'offline'
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
# Use a short timeout, and don't verify SSL certs for local services
response = requests.head(url, headers=headers, timeout=5, verify=False, allow_redirects=True)
# If we get ANY response, the server is online/reachable.
status = 'online'
except requests.exceptions.SSLError:
# SSL Handshake failed, but server is there.
status = 'online'
except requests.exceptions.RequestException:
# Connection failed (Timeout, DNS, Refused)
# Try one last time with GET, sometimes HEAD is blocked/buggy
try:
requests.get(url, headers=headers, timeout=5, verify=False, stream=True)
status = 'online'
except Exception as e2:
# Log only actual failures
print(f"Check URL failed for {url}: {e2}", file=sys.stdout, flush=True)
status = 'offline'
# Update Cache
URL_CACHE[url] = (status, current_time)
return jsonify({'status': status})
@app.route('/search')
def search():
query = request.args.get('q', '').strip().lower()
if not query or len(query) < 2:
return jsonify([])
search_term = f"%{query}%"
results = db.session.query(Link, Group.name, Page.name)\
.join(Group, Link.group_id == Group.id)\
.join(Page, Group.page_id == Page.id)\
.filter(
(func.lower(Link.name).like(search_term)) |
(func.lower(Link.subtitle).like(search_term))
).all()
output = [{
'name': link.name,
'url': link.url,
'subtitle': link.subtitle,
'icon': link.icon,
'group_name': group_name,
'page_name': page_name
} for link, group_name, page_name in results]
return jsonify(output)
# --- Widget Data Handlers ---
def get_pihole_status(config):
user_url = config.get('api_url', '').rstrip('/')
password = config.get('api_key')
if not user_url:
return {'error': "Pi-hole API URL not configured."}
try:
if user_url.endswith('/api'):
v6_api_base = user_url + '/'
else:
v6_api_base = urljoin(user_url, 'api/')
# Step 1: Authenticate
auth_url = urljoin(v6_api_base, 'auth')
auth_response = requests.post(auth_url, json={'password': password}, verify=False, timeout=5)
if auth_response.status_code == 404:
raise requests.exceptions.RequestException("Auth endpoint not found (404), falling back to v5.")
auth_response.raise_for_status()
sid = auth_response.json().get('session', {}).get('sid')
if not sid:
return {'error': 'Pi-hole v6 auth successful, but no SID returned.'}
# Step 2a: Get blocking status
blocking_url = urljoin(v6_api_base, 'dns/blocking')
blocking_response = requests.get(blocking_url, params={'sid': sid}, verify=False, timeout=5)
blocking_response.raise_for_status()
blocking_data = blocking_response.json()
is_enabled = blocking_data.get('blocking') == 'enabled'
status = 'enabled' if is_enabled else 'disabled'
# Step 2b: Get summary stats
summary_url = urljoin(v6_api_base, 'stats/summary')
summary_response = requests.get(summary_url, params={'sid': sid}, verify=False, timeout=5)
summary_response.raise_for_status()
summary_data = summary_response.json()
# Parse nested v6 structure
queries_data = summary_data.get('queries', {})
gravity_data = summary_data.get('gravity', {})
domains = gravity_data.get('domains_being_blocked', 0)
queries = queries_data.get('total', 0)
percent = queries_data.get('percent_blocked', 0.0)
return {
'status': status,
'domains_being_blocked': domains,
'dns_queries_today': queries,
'ads_percentage_today': percent
}
except Exception as e:
# Fallback for v5
try:
params = {'summary': ''}
if password:
params['auth'] = password
v5_base_url = user_url
if v5_base_url.endswith('/api'):
v5_base_url = v5_base_url[:-4]
v5_url = f"{v5_base_url.rstrip('/')}/admin/api.php"
response = requests.get(v5_url, params=params, timeout=5, verify=False)
response.raise_for_status()
v5_data = response.json()
if 'ads_percentage_today' in v5_data and isinstance(v5_data['ads_percentage_today'], str):
v5_data['ads_percentage_today'] = float(v5_data['ads_percentage_today'].replace(',', '.'))
return v5_data
except Exception as e2:
return {'error': f'Pi-hole connection failed: {e2}'}
def get_openmeteo_weather(config):
lat = config.get('latitude')
lon = config.get('longitude')
if not lat or not lon:
return {'error': 'Latitude/Longitude not configured.'}
try:
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,weather_code,wind_speed_10m",
"daily": "temperature_2m_max,temperature_2m_min,sunrise,sunset",
"timezone": "auto"
}
response = requests.get(url, params=params, timeout=5)
response.raise_for_status()
data = response.json()
return {
'current': data.get('current', {}),
'daily': data.get('daily', {}),
'units': data.get('current_units', {})
}
except Exception as e:
return {'error': f"Weather API failed: {e}"}
def get_iframe_widget(config):
return {
'url': config.get('url'),
'height': config.get('height', '300')
}
WIDGET_HANDLERS = {
'pihole_status': get_pihole_status,
'openmeteo_weather': get_openmeteo_weather,
'iframe_widget': get_iframe_widget
}
@app.route('/api/widget_data/<int:widget_id>')
def widget_data(widget_id):
widget = Widget.query.get_or_404(widget_id)
try:
config = json.loads(widget.config) if widget.config else {}
except json.JSONDecodeError:
return jsonify({'error': 'Invalid widget configuration format.'}), 500
handler = WIDGET_HANDLERS.get(widget.type)
if not handler:
return jsonify({'error': f"No handler for widget type '{widget.type}'."}), 404
data = handler(config)
if 'error' in data:
return jsonify(data), 500
return jsonify(data)
if __name__ == '__main__':
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.run(host='0.0.0.0', port=8080)

53
app/data.json Normal file
View File

@@ -0,0 +1,53 @@
{
"settings": {
"title": "Klenzel::Startseite",
"bg_image": "bg_wallpaper2.jpg",
"columns": "is-one-quarter-desktop",
"theme_color": "#3273dc",
"overlay_opacity": "0",
"header_color": "#767474",
"logo_image": "logo_logo_skaliert.png"
},
"pages": [
{
"name": "Infrastruktur",
"icon": "fas fa-home",
"groups": [
{
"name": "Switche",
"links": [
{
"name": "WLAN",
"url": "http://hkl-01-cl-cams:8081",
"icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/amazon.png",
"subtitle": "Testbeschreibung",
"target": "_self"
},
{
"name": "LAN",
"url": "172.30.130.251:2376",
"icon": "icon_dbdfee9ac94f45adb7e8ced1842d6213.png",
"subtitle": "",
"target": "_self"
},
{
"name": "Transporth\u00fclle",
"url": "http://hkl-01-cl-cams:8081",
"icon": "fas fa-link",
"subtitle": "Test",
"target": "_blank"
},
{
"name": "eth0",
"url": "172.25.16.161:2376",
"icon": "icon_0bec1ea5e7964532b98d88c567d67266.png",
"subtitle": "Test",
"target": "_blank"
}
]
}
]
}
],
"settings_configured": true
}

6
app/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask
Flask-SQLAlchemy
Pillow
requests
SQLAlchemy==2.0.29

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

875
app/templates/admin.html Normal file

File diff suppressed because one or more lines are too long

22
app/templates/empty.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title>Setup</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
</head>
<body>
<section class="hero is-fullheight is-info">
<div class="hero-body">
<div class="">
<p class="title">
Willkommen!
</p>
<p class="subtitle">
Dein Dashboard ist noch leer.
</p>
<a href="/admin" class="button is-white">Zum Admin-Bereich</a>
</div>
</div>
</section>
</body>
</html>

642
app/templates/index.html Normal file
View File

@@ -0,0 +1,642 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ data.settings.title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--theme-color: {{ data.settings.theme_color }};
--header-font-color: {{ data.settings.header_font_color|default('#FFFFFF') }};
--header-inactive-font-color: {{ data.settings.header_inactive_font_color|default('#C0C0C0') }};
--group-title-font-color: {{ data.settings.group_title_font_color|default('#FFFFFF') }};
}
body {
background-color: #333; min-height: 100vh;
{% if data.settings.bg_image %}
background-image: url('/static/uploads/{{ data.settings.bg_image }}');
background-size: cover; background-position: center; background-attachment: fixed;
{% endif %}
}
.body-overlay { background-color: rgba(255, 255, 255, {{ data.settings.overlay_opacity }}); min-height: 100vh; }
.navbar.custom-header { background-color: {{ data.settings.header_color }}; box-shadow: 0 2px 10px rgba(0,0,0,0.3); }
.navbar.custom-header .navbar-item,
.navbar.custom-header strong {
color: var(--header-font-color);
}
.navbar.custom-header .navbar-item.has-text-grey-light {
color: var(--header-inactive-font-color);
}
.navbar.custom-header .navbar-start {
margin-left: 20px;
}
.logo-img { max-height: 2.5rem !important; margin-right: 15px; }
.navbar-item.is-active {
background-color: var(--theme-color) !important;
color: var(--header-font-color) !important;
padding: 5px 10px !important;
margin: 5px 0 !important;
}
a.navbar-item:hover { background-color: rgba(255,255,255,0.1); color: var(--header-font-color); }
.navbar-start .navbar-item {
min-width: 150px;
justify-content: center;
padding: 5px 10px !important;
margin: 5px 0 !important;
}
#search-dropdown .dropdown-menu {
right: 0;
left: auto;
min-width: 25rem;
}
.group-title {
color: var(--group-title-font-color);
text-shadow: 1px 1px 3px rgba(0,0,0,0.8);
margin-bottom: 1rem !important;
font-weight: 600;
display: flex;
align-items: center;
}
.container { /* Added by Gemini */
max-width: 80% !important;
width: 80% !important;
}
.group-column {
padding: 0.75rem !important; /* Reduced from 1.5rem to fit 3 columns */
margin-bottom: 1.5rem !important;
}
{% if data.settings.overlay_opacity|float > 0.6 %}
.group-title { color: #363636; text-shadow: none; }
{% endif %}
.card {
border-radius: 16px; /* More rounded */
border: 1px solid rgba(255, 255, 255, 0.25); /* Iced edge */
border-top: 1px solid rgba(255, 255, 255, 0.5); /* Highlight top edge */
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
background-color: rgba({{ data.settings.group_bg_color_rgb | default('255,255,255') }}, {{ data.settings.box_glassiness | default(0.5) }});
backdrop-filter: blur(15px); /* Stronger blur */
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.3s ease, box-shadow 0.3s ease; /* Smooth animation */
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border-color: rgba(255, 255, 255, 0.4);
}
.card-content {
flex-grow: 1;
}
/* Widget Styles */
.widget {
margin-bottom: 1.5rem;
}
/* Apply glass effect to widget notifications too if they are used */
.widget .notification {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
backdrop-filter: blur(15px);
}
.widgets-sidebar {
padding: 0.75rem;
}
.link-item {
position: relative;
padding: 12px 15px; /* More breathing room */
border-bottom: 1px solid rgba(0,0,0,0.05); /* Subtler separator */
transition: background-color 0.2s;
display: flex; align-items: center; color: #4a4a4a;
height: 85px !important;
box-sizing: border-box;
overflow: hidden;
}
.link-item:hover {
background-color: rgba(255,255,255,0.4) !important; /* Lighter hover for glass feel */
color: var(--theme-color);
}
.link-item:last-child { border-bottom: none; }
.status-indicator {
position: absolute;
top: 10px;
right: 10px;
width: 10px;
height: 10px;
background-color: #ccc;
border-radius: 50%;
transition: background-color 0.3s;
}
.status-indicator.is-online {
background-color: #48c774;
}
.status-indicator.is-offline {
background-color: #f14668;
}
.icon-container {
width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
font-size: 1.8rem; margin-right: 15px; color: var(--theme-color); flex-shrink: 0;
}
.icon-container img { max-width: 100%; max-height: 100%; object-fit: contain; }
.link-content {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
}
.link-content p.title,
.link-content p.subtitle {
margin: 0;
}
.link-content p.title {
font-size: 1.1rem; /* Increased font size */
color: #363636;
font-weight: 600;
line-height: 1.3;
}
.link-content p.subtitle {
font-size: 0.9rem; /* Increased font size */
color: #888;
line-height: 1.3;
}
#clock {
font-size: 1.1rem; /* Increased font size */
color: var(--header-font-color); /* Ensure it uses the header font color */
}
</style>
<style>
.link-item .link-content p.subtitle {
margin-top: 0 !important;
}
</style>
</head>
<body>
<div class="body-overlay">
<nav class="navbar custom-header" role="navigation">
<div class="navbar-brand">
<a class="navbar-item" href="#">
{% if data.settings.logo_image %}
<img src="/static/uploads/{{ data.settings.logo_image }}" class="logo-img">
{% endif %}
<strong class="is-size-4 has-text-white">{{ data.settings.title }}</strong>
</a>
</div>
<div class="navbar-menu is-active" style="background: transparent;">
<div class="navbar-start">
{% if data.pages %}
{% for page in data.pages %}
<a class="navbar-item {% if loop.index0 == active_index %}is-active has-text-white{% else %}has-text-grey-light{% endif %}"
href="/?page={{ loop.index0 }}">
{% if page.icon %}
{% if '.' in page.icon or '/' in page.icon %}
<img src="{{ '/static/uploads/' + page.icon if not 'http' in page.icon else page.icon }}" style="width:20px; margin-right:8px;">
{% else %}
<i class="{{ page.icon }}" style="width:20px; margin-right:8px; text-align:center;"></i>
{% endif %}
{% endif %}
{{ page.name }}
</a>
{% endfor %}
{% endif %}
</div>
<div class="navbar-end">
<span class="navbar-item" id="clock"></span>
<div class="navbar-item">
<div class="dropdown" id="search-dropdown">
<div class="dropdown-trigger">
<div class="control has-icons-left">
<input class="input is-small" type="text" id="search-input" placeholder="Suchen..." aria-haspopup="true" aria-controls="search-results">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</div>
</div>
<div class="dropdown-menu" id="search-results" role="menu">
<div class="dropdown-content">
<!-- JS will populate this -->
</div>
</div>
</div>
</div>
<a class="navbar-item has-text-white" href="/admin"><i class="fas fa-cog"></i></a>
</div>
</div>
</nav>
<div class="container p-4">
{% if active_index == 0 %}
<div class="columns is-centered mb-5">
<div class="column is-half">
<form action="https://www.google.com/search" method="GET">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-medium" type="text" name="q" placeholder="Google Suche..." style="border-radius: 20px 0 0 20px; border: 1px solid rgba(255,255,255,0.3); border-right: none; box-shadow: 0 4px 15px rgba(0,0,0,0.1); background-color: rgba(255,255,255,0.7); backdrop-filter: blur(10px); color: #333;">
</div>
<div class="control">
<button class="button is-medium" type="submit" style="border-radius: 0 20px 20px 0; border: 1px solid rgba(255,255,255,0.3); border-left: none; box-shadow: 0 4px 15px rgba(0,0,0,0.1); background-color: var(--theme-color); color: white; opacity: 0.9;">
<span class="icon"><i class="fas fa-search"></i></span>
</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
{% if active_page %}
{# Dynamic Layout Logic #}
{% set has_widgets = active_page.widgets|length > 0 %}
{% set user_cols = data.settings.columns %}
{# Defaults (No Widgets) #}
{% set main_col_width = 'is-12' %}
{% set sidebar_width = '' %}
{% set item_col_width = user_cols %}
{% if has_widgets %}
{% if user_cols == 'is-one-third-desktop' %} {# 3 Cols -> 2 + 1 #}
{% set main_col_width = 'is-two-thirds' %} {# 66% #}
{% set sidebar_width = 'is-one-third' %} {# 33% #}
{% set item_col_width = 'is-half-desktop' %} {# 50% of 66% = 33% total #}
{% elif user_cols == 'is-one-quarter-desktop' %} {# 4 Cols -> 3 + 1 #}
{% set main_col_width = 'is-three-quarters' %} {# 75% #}
{% set sidebar_width = 'is-one-quarter' %} {# 25% #}
{% set item_col_width = 'is-one-third-desktop' %}{# 33% of 75% = 25% total #}
{% elif user_cols == 'is-one-fifth-desktop' %} {# 5 Cols -> 4 + 1 #}
{% set main_col_width = 'is-four-fifths' %} {# 80% #}
{% set sidebar_width = 'is-one-fifth' %} {# 20% #}
{% set item_col_width = 'is-one-quarter-desktop' %}{# 25% of 80% = 20% total #}
{% endif %}
{% endif %}
<div class="columns is-variable is-4"> <!-- Main Grid -->
<!-- Link Groups Column -->
<div class="column {{ main_col_width }}">
<div class="columns is-multiline is-variable is-4">
{% for group in active_page.groups %}
<div class="column {{ item_col_width }} is-full-mobile group-column">
<!-- Remove height:100% to allow auto-height based on content -->
<div style="display: flex; flex-direction: column;">
<h3 class="title is-5 group-title">
{% if group.icon %}
{% if '.' in group.icon or '/' in group.icon %}
<img src="{{ '/static/uploads/' + group.icon if not 'http' in group.icon else group.icon }}" style="height:24px; margin-right:10px;">
{% else %}
<i class="{{ group.icon }}" style="margin-right:10px;"></i>
{% endif %}
{% endif %}
{{ group.name }}
</h3>
<div class="card" style="flex-grow: 1;">
<div class="card-content p-0">
{% for link in group.links %}
<a href="{{ link.url }}" target="{{ link.target|default('_self') }}" class="link-item">
<span class="status-indicator"></span>
<div class="icon-container">
{% if '.' in link.icon or '/' in link.icon %}
<img src="{{ '/static/uploads/' + link.icon if not 'http' in link.icon else link.icon }}">
{% else %}
<i class="{{ link.icon }}"></i>
{% endif %}
</div>
<div class="link-content">
<p class="title">{{ link.name }}</p>
{% if link.subtitle %}
<p class="subtitle">{{ link.subtitle }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Sidebar Widgets Section -->
{% if has_widgets %}
<div class="column {{ sidebar_width }}">
<div style="margin-top: 2.5rem;"> <!-- Align with cards (offsetting the title) -->
{% for widget in active_page.widgets %}
<div class="widget mb-5" data-widget-id="{{ widget.id }}" data-widget-type="{{ widget.type }}">
<div class="notification is-light">
Lade Widget...
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Search functionality
const searchInput = document.getElementById('search-input');
const searchDropdown = document.getElementById('search-dropdown');
const searchResultsContainer = document.querySelector('#search-results .dropdown-content');
if (searchInput && searchDropdown && searchResultsContainer) {
searchInput.addEventListener('keyup', async function(event) {
const searchTerm = event.target.value.toLowerCase();
if (searchTerm.length < 2) {
searchDropdown.classList.remove('is-active');
return;
}
const response = await fetch(`/search?q=${encodeURIComponent(searchTerm)}`);
const results = await response.json();
searchResultsContainer.innerHTML = '';
if (results.length > 0) {
results.forEach(result => {
const resultElement = document.createElement('a');
resultElement.href = result.url;
resultElement.target = '_blank';
resultElement.classList.add('dropdown-item');
let iconHtml = '';
if (result.icon) {
if (result.icon.includes('.') || result.icon.includes('/')) {
const src = result.icon.startsWith('http') ? result.icon : `/static/uploads/${result.icon}`;
iconHtml = `<img src="${src}" style="height:24px; width: 24px; margin-right:10px; object-fit: contain;">`;
} else {
iconHtml = `<i class="${result.icon}" style="width: 24px; margin-right:10px; text-align: center;"></i>`;
}
} else {
iconHtml = `<span style="height:24px; width: 24px; margin-right:10px;"></span>`;
}
resultElement.innerHTML = `
<div style="display: flex; align-items: center;">
${iconHtml}
<div>
<p style="font-weight: 600;">${result.name}</p>
<p style="font-size: 0.8rem; color: #888;">${result.subtitle || ''}</p>
<p style="font-size: 0.7rem; color: #aaa;">Seite: ${result.page_name} / Gruppe: ${result.group_name}</p>
</div>
</div>
`;
searchResultsContainer.appendChild(resultElement);
});
} else {
const noResult = document.createElement('div');
noResult.classList.add('dropdown-item');
noResult.textContent = 'Keine Ergebnisse gefunden.';
searchResultsContainer.appendChild(noResult);
}
searchDropdown.classList.add('is-active');
});
document.addEventListener('click', function(event) {
if (!searchDropdown.contains(event.target)) {
searchDropdown.classList.remove('is-active');
}
});
}
// Link Status Checker
const allLinks = document.querySelectorAll('.link-item');
allLinks.forEach(link => {
const url = link.href;
const indicator = link.querySelector('.status-indicator');
if (!indicator) return;
if (!url || !url.startsWith('http')) {
indicator.style.display = 'none';
return;
}
fetch(`/check_url?url=${encodeURIComponent(url)}`)
.then(response => response.json())
.then(data => {
if (data.status === 'online') {
indicator.classList.add('is-online');
} else {
indicator.classList.add('is-offline');
}
})
.catch(error => {
indicator.classList.add('is-offline');
});
});
// Clock
const clockElement = document.getElementById('clock');
function updateClock() {
if (clockElement) {
const now = new Date();
clockElement.textContent = now.toLocaleTimeString('de-DE');
}
}
setInterval(updateClock, 1000);
updateClock(); // initial call
// --- Widget Rendering ---
const renderPiholeStatus = (widgetElement, data) => {
if (data.error) {
widgetElement.innerHTML = `<div class="notification is-danger"><strong>Pi-hole Error:</strong> ${data.error}</div>`;
return;
}
const statusClass = data.status === 'enabled' ? 'is-success' : 'is-warning';
widgetElement.innerHTML = `
<div class="card">
<header class="card-header">
<p class="card-header-title">
<span class="icon"><i class="fas fa-chart-bar"></i></span>
Pi-hole Status
</p>
</header>
<div class="card-content">
<div class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="heading">Status</p>
<p class="title is-4"><span class="tag ${statusClass} is-medium">${data.status}</span></p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Blocked</p>
<p class="title is-4">${parseFloat(data.ads_percentage_today || 0).toLocaleString('de-DE')}%</p>
</div>
</div>
</div>
<div class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="heading">Queries</p>
<p class="title is-5">${parseInt(data.dns_queries_today || 0).toLocaleString('de-DE')}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Blocklist</p>
<p class="title is-5">${parseInt(data.domains_being_blocked || 0).toLocaleString('de-DE')}</p>
</div>
</div>
</div>
</div>
</div>
`;
};
const renderOpenMeteoWeather = (widgetElement, data) => {
if (data.error) {
widgetElement.innerHTML = `<div class="notification is-danger"><strong>Weather Error:</strong> ${data.error}</div>`;
return;
}
const current = data.current || {};
const daily = data.daily || {};
const units = data.units || {};
const temp = current.temperature_2m;
const wind = current.wind_speed_10m;
const code = current.weather_code;
// Simple icon mapping (very basic)
let iconClass = 'fa-sun';
if (code > 3) iconClass = 'fa-cloud-sun';
if (code > 45) iconClass = 'fa-cloud-rain';
if (code > 70) iconClass = 'fa-snowflake';
if (code > 95) iconClass = 'fa-bolt';
const sunrise = daily.sunrise && daily.sunrise[0] ? daily.sunrise[0].split('T')[1] : '--:--';
const sunset = daily.sunset && daily.sunset[0] ? daily.sunset[0].split('T')[1] : '--:--';
widgetElement.innerHTML = `
<div class="card"> <!-- Use standard glass card -->
<div class="card-content">
<div class="level is-mobile">
<div class="level-left">
<div>
<p class="heading" style="color: #888;">Wetter</p>
<p class="title is-3" style="color: var(--theme-color);">${temp} ${units.temperature_2m}</p>
<p class="subtitle is-6" style="color: #666;"><i class="fas fa-wind mr-1"></i> ${wind} ${units.wind_speed_10m}</p>
</div>
</div>
<div class="level-right has-text-centered">
<div>
<i class="fas ${iconClass} fa-3x" style="color: var(--theme-color); opacity: 0.8;"></i>
</div>
</div>
</div>
<div class="level is-mobile mt-2" style="border-top: 1px solid rgba(0,0,0,0.05); padding-top: 10px;">
<div class="level-item has-text-centered">
<div>
<p class="heading" style="color: #aaa; margin-bottom: 0;">Aufgang</p>
<p class="is-size-6" style="color: #555;"><i class="fas fa-sun mr-1" style="color: #FFD700;"></i> ${sunrise}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading" style="color: #aaa; margin-bottom: 0;">Untergang</p>
<p class="is-size-6" style="color: #555;"><i class="fas fa-moon mr-1" style="color: #666;"></i> ${sunset}</p>
</div>
</div>
</div>
</div>
</div>
`;
};
const renderIframeWidget = (widgetElement, data) => {
const height = data.height || '300';
widgetElement.innerHTML = `
<div class="card" style="overflow: hidden;">
<iframe src="${data.url}" style="width: 100%; height: ${height}px; border: none;"></iframe>
</div>
`;
};
const WIDGET_RENDERERS = {
'pihole_status': renderPiholeStatus,
'openmeteo_weather': renderOpenMeteoWeather,
'iframe_widget': renderIframeWidget
};
// Fetch and render widgets
document.querySelectorAll('.widget').forEach(widgetElement => {
const widgetId = widgetElement.dataset.widgetId;
const widgetType = widgetElement.dataset.widgetType;
const renderer = WIDGET_RENDERERS[widgetType];
if (!widgetId || !renderer) {
widgetElement.innerHTML = '<div class="notification is-danger">Widget-Konfigurationsfehler.</div>';
return;
}
fetch(`/api/widget_data/${widgetId}`)
.then(response => {
// Always parse the JSON to get access to the body, regardless of status
return response.json().then(data => {
if (!response.ok) {
// If response is not ok, pass the JSON data to the catch block
return Promise.reject(data);
}
// If response is ok, pass the JSON data to the next .then()
return data;
});
})
.then(data => {
// This is the success handler
renderer(widgetElement, data);
})
.catch(errorData => {
// errorData is now the JSON object from the server
console.error('Error fetching widget data:', errorData);
let errorMessage = 'Ein unbekannter Fehler ist aufgetreten.';
let debugInfo = '';
if (errorData && errorData.error) {
errorMessage = errorData.error;
}
if (errorData && errorData.debug) {
debugInfo = `<pre style="white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; margin-top: 10px; background-color: #f5f5f5; padding: 5px; color: #333; font-size: 0.75rem;">${errorData.debug.join('\n')}</pre>`;
}
widgetElement.innerHTML = `<div class="notification is-danger"><strong>Fehler:</strong> ${errorMessage}${debugInfo}</div>`;
});
});
});
</script>
</body>
</html>

52
app/templates/login.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
</head>
<body>
<section class="section">
<div class="container">
<div class="columns is-centered">
<div class="column is-half">
<h1 class="title">Admin Login</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="notification is-{{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('login', next=request.args.get('next')) }}">
<div class="field">
<label class="label">Benutzername</label>
<div class="control">
<input class="input" type="text" name="username" required>
</div>
</div>
<div class="field">
<label class="label">Passwort</label>
<div class="control">
<input class="input" type="password" name="password" required>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Login</button>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
</body>
</html>

23
app/templates/media.html Normal file
View File

@@ -0,0 +1,23 @@
<tbody id="mediaTableBody">
{% for file in files %}
<tr>
<td style="width: 50px;">
{% if file.type == 'Icon' or file.type == 'Hintergrund' or file.type == 'Logo' %}
<img src="/static/uploads/{{ file.name }}" style="max-height: 30px;">
{% else %}
<i class="fas fa-file"></i>
{% endif %}
</td>
<td>{{ file.name }}</td>
<td>{{ file.size }}</td>
<td>{{ file.type }}</td>
<td>
<a href="/admin/delete_media/{{ file.name }}" class="button is-danger is-small" onclick="return confirm('Sicher?')">Löschen</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="has-text-centered">Keine Dateien vorhanden.</td>
</tr>
{% endfor %}
</tbody>

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
version: '3.8'
services:
dashboard:
build: ./app
container_name: custom_dashboard
network_mode: "host"
volumes:
- ./app/static/uploads:/app/static/uploads
- data:/data
env_file:
- .env
restart: unless-stopped
privileged: true
volumes:
data: {}

58522
fa_meta.json Normal file

File diff suppressed because one or more lines are too long

51
generate_fa_list.py Normal file
View File

@@ -0,0 +1,51 @@
import json
import re
# 1. Generate FA list from metadata
try:
with open('fa_meta.json', 'r') as f:
data = json.load(f)
fa_icons = []
for name, info in data.items():
styles = info.get('styles', [])
for style in styles:
prefix = 'fa-solid' if style == 'solid' else \
'fa-regular' if style == 'regular' else \
'fa-brands' if style == 'brands' else \
'fa-light'
if prefix == 'fa-light': continue
fa_icons.append(f"{prefix} fa-{name}")
fa_js = ' const fontawesomeIcons = [\n ' + ', '.join([f'"{icon}"' for icon in fa_icons]) + '\n ]'
print(f"Generated {len(fa_icons)} FA icons.")
except Exception as e:
print(f"Error processing FA json: {e}")
fa_js = ''
# 2. Read existing Homelab list from file to preserve it
homelab_js = ''
try:
with open('app/templates/admin.html', 'r') as f:
content = f.read()
# Escape brackets in regex for literal match
match = re.search(r'const homelabIcons = \[.*?\].*?;', content, re.DOTALL)
if match:
homelab_js = match.group(0)
print(f"Found existing homelab icons block.")
else:
print("Did NOT find homelab icons block.")
# Fallback for debugging
print(content[:500])
except Exception as e:
print(f"Error reading admin.html: {e}")
# 3. Write replacement
if homelab_js and fa_js:
with open('replacement_fa.txt', 'w') as f:
f.write(homelab_js + '\n\n' + fa_js)
print("Wrote replacement_fa.txt")
else:
print("Skipping write because data missing.")

71
generate_lists.py Normal file
View File

@@ -0,0 +1,71 @@
import json
# Read homelab icons
with open('homelab_icons_list.txt', 'r') as f:
homelab_icons = [line.strip().replace(',', '').replace('"', '') for line in f.readlines()]
# Generate JS array string
homelab_js = ' const homelabIcons = [\n ' + ', '.join([f'"{icon}"' for icon in homelab_icons]) + '\n ];'
# FontAwesome Icons (Expanded List)
fa_icons = [
# Web Application Icons
"fa-solid fa-house", "fa-solid fa-magnifying-glass", "fa-solid fa-user", "fa-solid fa-check", "fa-solid fa-download", "fa-solid fa-image",
"fa-solid fa-phone", "fa-solid fa-bars", "fa-solid fa-envelope", "fa-solid fa-star", "fa-solid fa-location-dot", "fa-solid fa-music",
"fa-solid fa-wand-magic-sparkles", "fa-solid fa-heart", "fa-solid fa-arrow-right", "fa-solid fa-circle-xmark", "fa-solid fa-bomb",
"fa-solid fa-poo", "fa-solid fa-camera-retro", "fa-solid fa-cloud", "fa-solid fa-comment", "fa-solid fa-pen-nib", "fa-solid fa-arrow-up",
"fa-solid fa-hippo", "fa-solid fa-face-smile", "fa-solid fa-calendar-days", "fa-solid fa-paperclip", "fa-solid fa-shield-halved",
"fa-solid fa-file", "fa-solid fa-bell", "fa-solid fa-cart-shopping", "fa-solid fa-clipboard", "fa-solid fa-filter", "fa-solid fa-circle-info",
"fa-solid fa-arrow-trend-up", "fa-solid fa-bolt", "fa-solid fa-car", "fa-solid fa-ghost", "fa-solid fa-mug-hot", "fa-solid fa-circle-user",
"fa-solid fa-pen", "fa-solid fa-umbrella", "fa-solid fa-gift", "fa-solid fa-film", "fa-solid fa-list", "fa-solid fa-gear",
"fa-solid fa-trash", "fa-solid fa-circle-up", "fa-solid fa-video", "fa-solid fa-chain", "fa-solid fa-gamepad", "fa-solid fa-server",
"fa-solid fa-database", "fa-solid fa-network-wired", "fa-solid fa-wifi", "fa-solid fa-microchip", "fa-solid fa-hard-drive",
"fa-solid fa-laptop", "fa-solid fa-desktop", "fa-solid fa-mobile", "fa-solid fa-tablet", "fa-solid fa-tv", "fa-solid fa-print",
"fa-solid fa-terminal", "fa-solid fa-code", "fa-solid fa-bug", "fa-solid fa-layer-group", "fa-solid fa-users", "fa-solid fa-user-secret",
"fa-solid fa-lock", "fa-solid fa-unlock", "fa-solid fa-key", "fa-solid fa-passport", "fa-solid fa-id-card", "fa-solid fa-address-book",
"fa-solid fa-globe", "fa-solid fa-earth-americas", "fa-solid fa-earth-europe", "fa-solid fa-earth-africa", "fa-solid fa-earth-asia",
"fa-solid fa-sun", "fa-solid fa-moon", "fa-solid fa-cloud-sun", "fa-solid fa-cloud-rain", "fa-solid fa-snowflake", "fa-solid fa-fire",
"fa-solid fa-water", "fa-solid fa-wind", "fa-solid fa-tree", "fa-solid fa-seedling", "fa-solid fa-leaf", "fa-solid fa-cannabis",
"fa-solid fa-paw", "fa-solid fa-dog", "fa-solid fa-cat", "fa-solid fa-horse", "fa-solid fa-fish", "fa-solid fa-crow",
"fa-solid fa-hospital", "fa-solid fa-syringe", "fa-solid fa-capsules", "fa-solid fa-pills", "fa-solid fa-briefcase-medical", "fa-solid fa-stethoscope",
"fa-solid fa-book", "fa-solid fa-book-open", "fa-solid fa-book-bookmark", "fa-solid fa-bookmark", "fa-solid fa-graduation-cap", "fa-solid fa-school",
"fa-solid fa-money-bill", "fa-solid fa-money-bill-wave", "fa-solid fa-coins", "fa-solid fa-credit-card", "fa-solid fa-wallet", "fa-solid fa-piggy-bank",
"fa-solid fa-chart-simple", "fa-solid fa-chart-pie", "fa-solid fa-chart-area", "fa-solid fa-chart-column", "fa-solid fa-chart-line", "fa-solid fa-diagram-project",
"fa-solid fa-thumbs-up", "fa-solid fa-thumbs-down", "fa-solid fa-hand-point-up", "fa-solid fa-hand-point-down", "fa-solid fa-hand-point-left", "fa-solid fa-hand-point-right",
"fa-solid fa-envelope-open", "fa-solid fa-envelope-open-text", "fa-solid fa-inbox", "fa-solid fa-paper-plane", "fa-solid fa-share", "fa-solid fa-share-nodes",
"fa-solid fa-clock", "fa-solid fa-stopwatch", "fa-solid fa-hourglass", "fa-solid fa-hourglass-start", "fa-solid fa-hourglass-half", "fa-solid fa-hourglass-end",
"fa-solid fa-calendar", "fa-solid fa-calendar-check", "fa-solid fa-calendar-plus", "fa-solid fa-calendar-minus", "fa-solid fa-calendar-xmark",
"fa-solid fa-rss", "fa-solid fa-podcast", "fa-solid fa-radio", "fa-solid fa-microphone", "fa-solid fa-microphone-lines", "fa-solid fa-headphones",
"fa-solid fa-sliders", "fa-solid fa-table-columns", "fa-solid fa-table-list", "fa-solid fa-table-cells", "fa-solid fa-table-cells-large",
"fa-solid fa-toggle-on", "fa-solid fa-toggle-off", "fa-solid fa-circle-check", "fa-solid fa-circle-pause", "fa-solid fa-circle-stop", "fa-solid fa-circle-play",
"fa-solid fa-power-off", "fa-solid fa-plug", "fa-solid fa-plug-circle-plus", "fa-solid fa-plug-circle-minus", "fa-solid fa-plug-circle-check", "fa-solid fa-plug-circle-xmark",
"fa-solid fa-ethernet", "fa-solid fa-satellite-dish", "fa-solid fa-satellite", "fa-solid fa-tower-broadcast", "fa-solid fa-tower-cell", "fa-solid fa-signal",
# Brands
"fa-brands fa-github", "fa-brands fa-docker", "fa-brands fa-linux", "fa-brands fa-windows", "fa-brands fa-apple", "fa-brands fa-android",
"fa-brands fa-google", "fa-brands fa-google-drive", "fa-brands fa-google-play", "fa-brands fa-aws", "fa-brands fa-cloudflare", "fa-brands fa-digital-ocean",
"fa-brands fa-python", "fa-brands fa-js", "fa-brands fa-html5", "fa-brands fa-css3", "fa-brands fa-react", "fa-brands fa-vuejs", "fa-brands fa-angular",
"fa-brands fa-node", "fa-brands fa-npm", "fa-brands fa-yarn", "fa-brands fa-php", "fa-brands fa-java", "fa-brands fa-rust", "fa-brands fa-golang",
"fa-brands fa-wordpress", "fa-brands fa-joomla", "fa-brands fa-drupal", "fa-brands fa-magento", "fa-brands fa-shopify", "fa-brands fa-squarespace",
"fa-brands fa-wix", "fa-brands fa-weebly", "fa-brands fa-medium", "fa-brands fa-tumblr", "fa-brands fa-blogger", "fa-brands fa-ghost",
"fa-brands fa-discord", "fa-brands fa-slack", "fa-brands fa-telegram", "fa-brands fa-whatsapp", "fa-brands fa-facebook-messenger", "fa-brands fa-skype",
"fa-brands fa-twitter", "fa-brands fa-facebook", "fa-brands fa-instagram", "fa-brands fa-linkedin", "fa-brands fa-youtube", "fa-brands fa-twitch",
"fa-brands fa-tiktok", "fa-brands fa-snapchat", "fa-brands fa-pinterest", "fa-brands fa-reddit", "fa-brands fa-quora", "fa-brands fa-stack-overflow",
"fa-brands fa-spotify", "fa-brands fa-itunes", "fa-brands fa-soundcloud", "fa-brands fa-bandcamp", "fa-brands fa-steam", "fa-brands fa-xbox",
"fa-brands fa-playstation", "fa-brands fa-battle-net", "fa-brands fa-itch-io", "fa-brands fa-unity", "fa-brands fa-unreal-engine",
"fa-brands fa-bitcoin", "fa-brands fa-ethereum", "fa-brands fa-paypal", "fa-brands fa-stripe", "fa-brands fa-cc-visa", "fa-brands fa-cc-mastercard",
"fa-brands fa-cc-amex", "fa-brands fa-cc-discover", "fa-brands fa-cc-jcb", "fa-brands fa-cc-diners-club", "fa-brands fa-amazon", "fa-brands fa-ebay",
"fa-brands fa-alipay", "fa-brands fa-kickstarter", "fa-brands fa-patreon", "fa-brands fa-trello", "fa-brands fa-atlassian", "fa-brands fa-jira",
"fa-brands fa-bitbucket", "fa-brands fa-confluence", "fa-brands fa-dropbox", "fa-brands fa-box", "fa-brands fa-evernote", "fa-brands fa-salesforce",
"fa-brands fa-hubspot", "fa-brands fa-mailchimp", "fa-brands fa-waze", "fa-brands fa-uber", "fa-brands fa-lyft", "fa-brands fa-airbnb",
"fa-brands fa-strava", "fa-brands fa-fitbit", "fa-brands fa-nutritionix", "fa-brands fa-imdb", "fa-brands fa-wikipedia-w", "fa-brands fa-researchgate",
"fa-brands fa-chrome", "fa-brands fa-firefox", "fa-brands fa-firefox-browser", "fa-brands fa-edge", "fa-brands fa-safari", "fa-brands fa-opera",
"fa-brands fa-internet-explorer", "fa-brands fa-ubuntu", "fa-brands fa-centos", "fa-brands fa-fedora", "fa-brands fa-redhat", "fa-brands fa-suse",
"fa-brands fa-raspberry-pi", "fa-brands fa-freebsd"
]
fa_js = ' const fontawesomeIcons = [\n ' + ', '.join([f'"{icon}"' for icon in fa_icons]) + '\n ];'
with open('replacement.txt', 'w') as f:
f.write(homelab_js + '\n\n' + fa_js)

2740
homelab_icons_list.txt Normal file

File diff suppressed because it is too large Load Diff

81967
icon_tree.json Normal file

File diff suppressed because it is too large Load Diff

7
replacement.txt Normal file

File diff suppressed because one or more lines are too long

6
replacement_fa.txt Normal file

File diff suppressed because one or more lines are too long

22
update_admin_fa.py Normal file
View File

@@ -0,0 +1,22 @@
import re
html_path = 'app/templates/admin.html'
replacement_path = 'replacement_fa.txt'
with open(html_path, 'r') as f:
content = f.read()
with open(replacement_path, 'r') as f:
new_icons_block = f.read()
# Regex to find the block
pattern = r'const homelabIcons = \[.*?\];.*?\s*const fontawesomeIcons = \[.*?\];'
if re.search(pattern, content, re.DOTALL):
new_content = re.sub(pattern, new_icons_block, content, flags=re.DOTALL)
with open(html_path, 'w') as f:
f.write(new_content)
print("Successfully replaced icon lists.")
else:
print("Could not find the icon lists block to replace.")

24
update_admin_html.py Normal file
View File

@@ -0,0 +1,24 @@
import re
html_path = 'app/templates/admin.html'
replacement_path = 'replacement.txt'
with open(html_path, 'r') as f:
content = f.read()
with open(replacement_path, 'r') as f:
new_icons_block = f.read()
# Regex to find the block
# Matches: const homelabIcons = [ ... ]; <whitespace> const fontawesomeIcons = [ ... ];
# We use dotall to match newlines.
pattern = r'const homelabIcons = \[.*?\];.*?\s*const fontawesomeIcons = \[.*?\];'
if re.search(pattern, content, re.DOTALL):
new_content = re.sub(pattern, new_icons_block, content, flags=re.DOTALL)
with open(html_path, 'w') as f:
f.write(new_content)
print("Successfully replaced icon lists.")
else:
print("Could not find the icon lists block to replace.")