initial commit

This commit is contained in:
2025-06-03 14:01:08 +00:00
commit 6ab9ceed4b
50 changed files with 2253 additions and 0 deletions

37
dashboard/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# dashboard/Dockerfile
# Produktions-Dockerfile für die Dash-Applikation
# --- Basis-Image ---
FROM python:3.13-slim
# --- Arbeitsverzeichnis im Container ---
WORKDIR /app
# --- Systemabhängigkeiten installieren (falls benötigt) ---
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# --- Python-Abhängigkeiten kopieren und installieren ---
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# --- Applikationscode kopieren ---
COPY dashboard/ /app
# --- Non-Root-User anlegen und Rechte setzen ---
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} \
--shell /bin/bash --create-home infoscreen_taa \
&& chown -R infoscreen_taa:infoscreen_taa /app
USER infoscreen_taa
# --- Port für Dash exposed ---
EXPOSE 8050
# --- Startbefehl: Gunicorn mit Dash-Server ---
# "app.py" enthält: app = dash.Dash(...); server = app.server
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8050", "app:server"]

47
dashboard/Dockerfile.dev Normal file
View File

@@ -0,0 +1,47 @@
# dashboard/Dockerfile.dev
# Entwicklungs-Dockerfile für das Dash-Dashboard
FROM python:3.13-slim
# Build args für UID/GID
ARG USER_ID=1000
ARG GROUP_ID=1000
# Systemabhängigkeiten (falls nötig)
RUN apt-get update \
&& apt-get install -y --no-install-recommends locales curl \
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
# Locale setzen
ENV LANG=de_DE.UTF-8 \
LANGUAGE=de_DE:de \
LC_ALL=de_DE.UTF-8
# Non-root User anlegen
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa
# Arbeitsverzeichnis
WORKDIR /app
# Kopiere nur Requirements für schnellen Rebuild
COPY requirements.txt ./
# Installiere Abhängigkeiten
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Setze Entwicklungs-Modus
ENV DASH_DEBUG_MODE=True
ENV API_URL=http://server:8000/api
# Ports für Dash und optional Live-Reload
EXPOSE 8050
# Wechsle zum non-root User
USER infoscreen_taa
# Dev-Start: Dash mit Hot-Reload
CMD ["tail", "-f", "/dev/null"]

0
dashboard/__init__.py Normal file
View File

32
dashboard/app.py Normal file
View File

@@ -0,0 +1,32 @@
# dashboard/app.py
from dash import Dash, html, dcc, page_container
from flask import Flask
import dash_bootstrap_components as dbc
from components.header import Header
# from components.sidebar import Sidebar
import callbacks.ui_callbacks # wichtig!
import dashboard.callbacks.overview_callbacks # <-- Das registriert die Callbacks
from config import SECRET_KEY, ENV
server = Flask(__name__)
server.secret_key = SECRET_KEY
app = Dash(
__name__,
server=server,
use_pages=True,
external_stylesheets=[dbc.themes.BOOTSTRAP],
suppress_callback_exceptions=True
)
app.layout = html.Div([
Header(),
dcc.Store(id="sidebar-state", data={"collapsed": False}),
html.Div(id="sidebar"), # Sidebar wird dynamisch gerendert
html.Div(page_container, className="page-content"),
])
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8050, debug=(ENV=="development"))

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

188
dashboard/assets/custom.css Normal file
View File

@@ -0,0 +1,188 @@
/* ==========================
Allgemeines Layout
========================== */
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
padding-top: 60px; /* Platz für den fixen Header schaffen */
}
/* page-content (rechts neben der Sidebar) */
.page-content {
margin-left: 220px;
padding: 20px;
transition: margin-left 0.3s ease;
min-height: calc(100vh - 60px); /* Mindesthöhe minus Header-Höhe */
}
/* Wenn Sidebar collapsed ist, reduziere margin-left */
.sidebar.collapsed ~ .page-content {
margin-left: 70px;
}
/* ==========================
Header
========================== */
.app-header {
position: fixed;
top: 0;
left: 0;
height: 60px;
width: 100%;
background-color: #e4d5c1;
color: #7c5617;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 1100;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.logo {
height: 40px;
margin-right: 10px;
}
.app-title {
font-size: 1.5rem;
font-weight: bold;
}
.org-name {
font-size: 1rem;
color: #7c5617;
}
.header-right {
display: flex;
align-items: center;
}
/* ==========================
Sidebar
========================== */
.sidebar {
width: 220px;
transition: width 0.3s ease;
background-color: #e4d5c1;
color: black;
height: calc(100vh - 60px); /* Höhe minus Header */
position: fixed;
top: 60px; /* Den gleichen Wert wie Header-Höhe verwenden */
left: 0;
z-index: 1000;
overflow-x: hidden;
overflow-y: auto;
}
.sidebar.collapsed {
width: 70px;
}
/* Sidebar Toggle Button (Burger-Icon) */
.sidebar-toggle {
text-align: right;
padding: 5px 10px;
}
.toggle-button {
background: none;
border: none;
color: #7c5617;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.1s ease, background-color 0.2s ease;
padding: 8px;
border-radius: 4px;
}
.toggle-button:hover {
background-color: #7c5617;
color: #e4d5c1;
transform: scale(1.05);
}
.toggle-button:active {
transform: scale(0.95);
}
/* Navigation in der Sidebar */
.sidebar-nav .nav-link {
color: #7c5617;
padding: 10px 15px;
display: flex;
align-items: center;
border-radius: 4px;
margin: 2px 8px;
transition: background-color 0.2s ease, color 0.2s ease;
}
.sidebar-nav .nav-link:hover {
background-color: #7c5617;
color: #e4d5c1;
}
.sidebar-nav .nav-link.active {
background-color: #7c5617;
color: #e4d5c1;
}
/* Text neben Icons */
.sidebar-label {
display: inline-block;
margin-left: 10px;
white-space: nowrap;
transition: opacity 0.3s ease, width 0.3s ease;
}
/* Wenn Sidebar collapsed ist, blendet das Label aus */
.sidebar.collapsed .sidebar-label {
opacity: 0;
width: 0;
overflow: hidden;
}
/* Tooltips (Bootstrap-Tooltips) */
.tooltip {
z-index: 2000;
background-color: #7c5617;
color: #e4d5c1;
}
/* Optional: Tooltips nur anzeigen, wenn Sidebar collapsed ist */
/* Da dash-bootstrap-components Tooltips in einen anderen DOM-Layer rendert,
kann man bei Bedarf per Callback steuern, ob sie geöffnet sind oder nicht.
Dieser Block ist nur ein Zusatz das Haupt-Show/Hiding erfolgt per
is_open-Callback. */
.sidebar:not(.collapsed) ~ .tooltip {
display: none !important;
}
/* ==========================
Responsive (bei Bedarf)
========================== */
@media (max-width: 768px) {
body {
padding-top: 60px; /* Header-Platz auch auf mobilen Geräten */
}
.sidebar {
position: fixed;
height: calc(100vh - 60px);
z-index: 1050;
}
.page-content {
margin-left: 0;
}
.sidebar.collapsed {
width: 0;
}
.sidebar.collapsed ~ .page-content {
margin-left: 0;
}
}

BIN
dashboard/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View File

View File

@@ -0,0 +1,27 @@
# dashboard/callbacks/appointments_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.db import execute_query, execute_non_query
@dash.callback(
Output("appointments-feedback", "children"),
Input("btn-add-appointment", "n_clicks"),
State("input-client-id", "value"),
State("input-appointment-date", "date"),
State("input-appointment-time", "value"),
State("input-appointment-desc", "value"),
prevent_initial_call=True
)
def add_appointment(n_clicks, client_id, date, time_str, desc):
if "role" not in session:
return dcc.Location(href="/login")
if n_clicks and n_clicks > 0:
datetime_str = f"{date} {time_str}:00"
sql = """
INSERT INTO appointments (client_id, start_datetime, description)
VALUES (%s, %s, %s)
"""
rc = execute_non_query(sql, (client_id, datetime_str, desc))
return "Erstellt." if rc else "Fehler beim Anlegen."
return ""

View File

@@ -0,0 +1,31 @@
# dashboard/callbacks/auth_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.auth import check_password, get_user_role
from config import ENV
from utils.db import execute_query
@dash.callback(
Output("login-feedback", "children"),
Output("header-right", "children"),
Input("btn-login", "n_clicks"),
State("input-user", "value"),
State("input-pass", "value"),
prevent_initial_call=True
)
def login_user(n_clicks, username, password):
if ENV == "development":
# DevBypass: setze immer AdminSession und leite weiter
session["username"] = "dev_admin"
session["role"] = "admin"
return dcc.Location(href="/overview", id="redirect-dev"), None
# ProduktionsLogin: User in DB suchen
user = execute_query("SELECT username, pwd_hash, role FROM users WHERE username=%s", (username,), fetch_one=True)
if user and check_password(password, user["pwd_hash"]):
session["username"] = user["username"]
session["role"] = user["role"]
return dcc.Location(href="/overview", id="redirect-ok"), None
else:
return "Ungültige Zugangsdaten.", None

View File

@@ -0,0 +1,104 @@
# dashboard/callbacks/overview_callbacks.py
import sys
print(sys.path)
import threading
import dash
from dash import Input, Output, State, MATCH, html, dcc
from flask import session
from utils.db import get_session # Diese Funktion muss eine SQLAlchemy-Session liefern!
from utils.mqtt_client import publish, start_loop
from config import ENV
import dash_bootstrap_components as dbc
import os
from server.models import Client
mqtt_thread_started = False
SCREENSHOT_DIR = "/workspace/received-screenshots"
def ensure_mqtt_running():
global mqtt_thread_started
if not mqtt_thread_started:
thread = threading.Thread(target=start_loop, daemon=True)
thread.start()
mqtt_thread_started = True
def get_latest_screenshot(client_uuid):
prefix = f"client_{client_uuid}_"
try:
files = [f for f in os.listdir(SCREENSHOT_DIR) if f.startswith(prefix)]
if not files:
return "/assets/placeholder.png"
latest = max(files, key=lambda x: os.path.getmtime(os.path.join(SCREENSHOT_DIR, x)))
return f"/received-screenshots/{latest}"
except Exception:
return "/assets/placeholder.png"
@dash.callback(
Output("clients-cards-container", "children"),
Input("interval-update", "n_intervals")
)
def update_clients(n):
# Auto-Login im Development-Modus
if "role" not in session:
if ENV == "development":
session["role"] = "admin"
else:
return dcc.Location(id="redirect-login", href="/login")
ensure_mqtt_running()
session_db = get_session()
clients = session_db.query(Client).all()
session_db.close()
cards = []
for client in clients:
uuid = client.uuid
screenshot = get_latest_screenshot(uuid)
card = dbc.Card(
[
dbc.CardHeader(client.location or client.hardware_hash),
dbc.CardBody([
html.Img(
src=screenshot,
style={"width": "160px", "height": "90px", "object-fit": "cover"},
),
html.P(f"IP: {client.ip_address or '-'}", className="card-text"),
html.P(f"Letzte Aktivität: {client.last_alive or '-'}", className="card-text"),
dbc.ButtonGroup([
dbc.Button("Reload Page", color="primary", id={"type": "btn-reload", "index": uuid}, n_clicks=0),
dbc.Button("Restart Client", color="danger", id={"type": "btn-restart", "index": uuid}, n_clicks=0),
], className="mt-2"),
html.Div(id={"type": "restart-feedback", "index": uuid}),
html.Div(id={"type": "reload-feedback", "index": uuid}),
]),
],
className="mb-4",
style={"width": "18rem"},
)
cards.append(dbc.Col(card, width=4))
return dbc.Row(cards)
@dash.callback(
Output({"type": "restart-feedback", "index": MATCH}, "children"),
Input({"type": "btn-restart", "index": MATCH}, "n_clicks"),
State({"type": "btn-restart", "index": MATCH}, "id")
)
def on_restart(n_clicks, btn_id):
if n_clicks and n_clicks > 0:
cid = btn_id["index"]
payload = '{"command": "restart"}'
ok = publish(f"clients/{cid}/control", payload)
return "Befehl gesendet." if ok else "Fehler beim Senden."
return ""
@dash.callback(
Output({"type": "reload-feedback", "index": MATCH}, "children"),
Input({"type": "btn-reload", "index": MATCH}, "n_clicks"),
State({"type": "btn-reload", "index": MATCH}, "id")
)
def on_reload(n_clicks, btn_id):
if n_clicks and n_clicks > 0:
cid = btn_id["index"]
payload = '{"command": "reload"}'
ok = publish(f"clients/{cid}/control", payload)
return "Befehl gesendet." if ok else "Fehler beim Senden."
return ""

View File

@@ -0,0 +1,20 @@
# dashboard/callbacks/settings_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.db import execute_query, execute_non_query
@dash.callback(
Output("settings-feedback", "children"),
Input("btn-save-settings", "n_clicks"),
State("input-default-volume", "value"),
prevent_initial_call=True
)
def save_settings(n_clicks, volume):
if "role" not in session:
return dcc.Location(href="/login")
if n_clicks and n_clicks > 0:
sql = "UPDATE global_settings SET value=%s WHERE key='default_volume'"
rc = execute_non_query(sql, (volume,))
return "Einstellungen gespeichert." if rc else "Speichern fehlgeschlagen."
return ""

View File

@@ -0,0 +1,31 @@
# dashboard/callbacks/ui_callbacks.py
from dash import Input, Output, State, callback
from components.sidebar import Sidebar
# 1) Toggle-Callback: Umschalten von collapsed = False ↔ True
@callback(
Output("sidebar-state", "data"),
Input("btn-toggle-sidebar", "n_clicks"),
State("sidebar-state", "data"),
prevent_initial_call=True
)
def toggle_sidebar(n_clicks, state):
# Wenn der Button geklickt wurde, invertiere den collapsed-Wert
collapsed = state.get("collapsed", False)
return {"collapsed": not collapsed}
# 2) Render-Callback: Zeichnet die Sidebar neu und setzt die CSS-Klasse
@callback(
[Output("sidebar", "children"), Output("sidebar", "className")],
Input("sidebar-state", "data")
)
def render_sidebar(state):
collapsed = state.get("collapsed", False)
sidebar_class = "sidebar collapsed" if collapsed else "sidebar"
# Sidebar() gibt jetzt nur den Inhalt zurück
sidebar_content = Sidebar(collapsed=collapsed)
return sidebar_content, sidebar_class

View File

@@ -0,0 +1,24 @@
# dashboard/callbacks/users_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.db import execute_query, execute_non_query
from utils.auth import hash_password
@dash.callback(
Output("users-feedback", "children"),
Input("btn-new-user", "n_clicks"),
State("input-new-username", "value"),
State("input-new-password", "value"),
State("input-new-role", "value"),
prevent_initial_call=True
)
def create_user(n_clicks, uname, pwd, role):
if session.get("role") != "admin":
return "Keine Berechtigung."
if n_clicks and n_clicks > 0:
pwd_hash = hash_password(pwd)
sql = "INSERT INTO users (username, pwd_hash, role) VALUES (%s, %s, %s)"
rc = execute_non_query(sql, (uname, pwd_hash, role))
return "Benutzer erstellt." if rc else "Fehler beim Erstellen."
return ""

View File

@@ -0,0 +1,13 @@
# dashboard/components/header.py
from dash import html
def Header():
return html.Div(
className="app-header",
children=[
html.Img(src="/assets/logo.png", className="logo"),
html.Span("Infoscreen-Manager", className="app-title"),
html.Span(" Organisationsname", className="org-name"),
html.Div(id="header-right", className="header-right") # Platzhalter für Login/ProfilButton
]
)

View File

@@ -0,0 +1,70 @@
# dashboard/components/sidebar.py
from dash import html
import dash_bootstrap_components as dbc
from dash_iconify import DashIconify
from typing import List, Any
def Sidebar(collapsed: bool = False) -> List[Any]:
"""
Gibt nur den Inhalt der Sidebar zurück (ohne das äußere div mit id="sidebar").
Das äußere div wird bereits in app.py definiert.
"""
nav_items = [
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
]
nav_links = []
for item in nav_items:
# Die ID muss in den Callbacks exakt so referenziert werden:
link_id = {"type": "nav-item", "index": item["label"]}
# ✅ NavLink erstellen
nav_link = dbc.NavLink(
[
DashIconify(icon=item["icon"], width=24),
html.Span(item["label"], className="ms-2 sidebar-label"),
],
href=item["href"],
active="exact",
className="sidebar-item",
id=link_id,
)
# ✅ Conditional List Construction - keine append() nötig
if collapsed:
tooltip = dbc.Tooltip(
item["label"],
target=link_id,
placement="right",
id=f"tooltip-{item['label']}",
)
link_components = [nav_link, tooltip]
else:
link_components = [nav_link]
# ✅ Alle Komponenten in einem div-Container
nav_links.append(
html.Div(
children=link_components,
className="nav-item-container"
)
)
# Gib nur den Inhalt zurück, nicht das äußere div
return [
html.Div(
className="sidebar-toggle",
children=html.Button(
DashIconify(icon="mdi:menu", width=28),
id="btn-toggle-sidebar",
className="toggle-button",
)
),
dbc.Nav(nav_links, vertical=True, pills=True, className="sidebar-nav")
]

28
dashboard/config.py Normal file
View File

@@ -0,0 +1,28 @@
# dashboard/config.py
import os
from dotenv import load_dotenv
# .env aus RootVerzeichnis laden
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
load_dotenv(os.path.join(base_dir, ".env"))
# DBEinstellungen
DB_HOST = os.getenv("DB_HOST")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_NAME = os.getenv("DB_NAME")
DB_POOL_NAME = os.getenv("DB_POOL_NAME", "my_pool")
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
# MQTTEinstellungen
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST")
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
MQTT_USERNAME = os.getenv("MQTT_USERNAME")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID")
# Sonstige Einstellungen
SECRET_KEY = os.getenv("SECRET_KEY", "changeme")
ENV = os.getenv("ENV", "development")

View File

@@ -0,0 +1,402 @@
import dash
import full_calendar_component as fcc
from dash import *
import dash_mantine_components as dmc
from dash.exceptions import PreventUpdate
from datetime import datetime, date, timedelta
import dash_quill
# dash._dash_renderer._set_react_version('18.2.0')
app = Dash(__name__, prevent_initial_callbacks=True)
quill_mods = [
[{"header": "1"}, {"header": "2"}, {"font": []}],
[{"size": []}],
["bold", "italic", "underline", "strike", "blockquote"],
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}],
["link", "image"],
]
# Get today's date
today = datetime.now()
# Format the date
formatted_date = today.strftime("%Y-%m-%d")
app.layout = html.Div(
[
fcc.FullCalendarComponent(
id="calendar", # Unique ID for the component
initialView="listWeek", # dayGridMonth, timeGridWeek, timeGridDay, listWeek,
# dayGridWeek, dayGridYear, multiMonthYear, resourceTimeline, resourceTimeGridDay, resourceTimeLineWeek
headerToolbar={
"left": "prev,next today",
"center": "",
"right": "listWeek,timeGridDay,timeGridWeek,dayGridMonth",
}, # Calendar header
initialDate=f"{formatted_date}", # Start date for calendar
editable=True, # Allow events to be edited
selectable=True, # Allow dates to be selected
events=[],
nowIndicator=True, # Show current time indicator
navLinks=True, # Allow navigation to other dates
),
dmc.MantineProvider(
theme={"colorScheme": "dark"},
children=[
dmc.Modal(
id="modal",
size="xl",
title="Event Details",
zIndex=10000,
children=[
html.Div(id="modal_event_display_context"),
dmc.Space(h=20),
dmc.Group(
[
dmc.Button(
"Close",
color="red",
variant="outline",
id="modal-close-button",
),
],
pos="right",
),
],
)
],
),
dmc.MantineProvider(
theme={"colorScheme": "dark"},
children=[
dmc.Modal(
id="add_modal",
title="New Event",
size="xl",
children=[
dmc.Grid(
children=[
dmc.GridCol(
html.Div(
dmc.DatePickerInput(
id="start_date",
label="Start Date",
value=datetime.now().date(),
styles={"width": "100%"},
disabled=True,
),
style={"width": "100%"},
),
span=6,
),
dmc.GridCol(
html.Div(
dmc.TimeInput(
label="Start Time",
withSeconds=True,
value=datetime.now(),
# format="12",
id="start_time",
),
style={"width": "100%"},
),
span=6,
),
],
gutter="xl",
),
dmc.Grid(
children=[
dmc.GridCol(
html.Div(
dmc.DatePickerInput(
id="end_date",
label="End Date",
value=datetime.now().date(),
styles={"width": "100%"},
),
style={"width": "100%"},
),
span=6,
),
dmc.GridCol(
html.Div(
dmc.TimeInput(
label="End Time",
withSeconds=True,
value=datetime.now(),
# format="12",
id="end_time",
),
style={"width": "100%"},
),
span=6,
),
],
gutter="xl",
),
dmc.Grid(
children=[
dmc.GridCol(
span=6,
children=[
dmc.TextInput(
label="Event Title:",
style={"width": "100%"},
id="event_name_input",
required=True,
)
],
),
dmc.GridCol(
span=6,
children=[
dmc.Select(
label="Select event color",
placeholder="Select one",
id="event_color_select",
value="ng",
data=[
{
"value": "bg-gradient-primary",
"label": "bg-gradient-primary",
},
{
"value": "bg-gradient-secondary",
"label": "bg-gradient-secondary",
},
{
"value": "bg-gradient-success",
"label": "bg-gradient-success",
},
{
"value": "bg-gradient-info",
"label": "bg-gradient-info",
},
{
"value": "bg-gradient-warning",
"label": "bg-gradient-warning",
},
{
"value": "bg-gradient-danger",
"label": "bg-gradient-danger",
},
{
"value": "bg-gradient-light",
"label": "bg-gradient-light",
},
{
"value": "bg-gradient-dark",
"label": "bg-gradient-dark",
},
{
"value": "bg-gradient-white",
"label": "bg-gradient-white",
},
],
style={"width": "100%", "marginBottom": 10},
required=True,
)
],
),
]
),
dash_quill.Quill(
id="rich_text_input",
modules={
"toolbar": quill_mods,
"clipboard": {
"matchVisual": False,
},
},
),
dmc.Accordion(
children=[
dmc.AccordionItem(
[
dmc.AccordionControl("Raw HTML"),
dmc.AccordionPanel(
html.Div(
id="rich_text_output",
style={
"height": "300px",
"overflowY": "scroll",
},
)
),
],
value="raw_html",
),
],
),
dmc.Space(h=20),
dmc.Group(
[
dmc.Button(
"Submit",
id="modal_submit_new_event_button",
color="green",
),
dmc.Button(
"Close",
color="red",
variant="outline",
id="modal_close_new_event_button",
),
],
pos="right",
),
],
),
],
),
]
)
@app.callback(
Output("modal", "opened"),
Output("modal", "title"),
Output("modal_event_display_context", "children"),
Input("modal-close-button", "n_clicks"),
Input("calendar", "clickedEvent"),
State("modal", "opened"),
)
def open_event_modal(n, clickedEvent, opened):
ctx = callback_context
if not ctx.triggered:
raise PreventUpdate
else:
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "calendar" and clickedEvent is not None:
event_title = clickedEvent["title"]
event_context = clickedEvent["extendedProps"]["context"]
return (
True,
event_title,
html.Div(
dash_quill.Quill(
id="input3",
value=f"{event_context}",
modules={
"toolbar": False,
"clipboard": {
"matchVisual": False,
},
},
),
style={"width": "100%", "overflowY": "auto"},
),
)
elif button_id == "modal-close-button" and n is not None:
return False, dash.no_update, dash.no_update
return opened, dash.no_update
@app.callback(
Output("add_modal", "opened"),
Output("start_date", "value"),
Output("end_date", "value"),
Output("start_time", "value"),
Output("end_time", "value"),
Input("calendar", "dateClicked"),
Input("modal_close_new_event_button", "n_clicks"),
State("add_modal", "opened"),
)
def open_add_modal(dateClicked, close_clicks, opened):
ctx = callback_context
if not ctx.triggered:
raise PreventUpdate
else:
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "calendar" and dateClicked is not None:
try:
start_time = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z").time()
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z")
start_date = start_date_obj.strftime("%Y-%m-%d")
end_date = start_date_obj.strftime("%Y-%m-%d")
except ValueError:
start_time = datetime.now().time()
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%d")
start_date = start_date_obj.strftime("%Y-%m-%d")
end_date = start_date_obj.strftime("%Y-%m-%d")
end_time = datetime.combine(date.today(), start_time) + timedelta(hours=1)
start_time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
end_time_str = end_time.strftime("%Y-%m-%d %H:%M:%S")
return True, start_date, end_date, start_time_str, end_time_str
elif button_id == "modal_close_new_event_button" and close_clicks is not None:
return False, dash.no_update, dash.no_update, dash.no_update, dash.no_update
return opened, dash.no_update, dash.no_update, dash.no_update, dash.no_update
@app.callback(
Output("calendar", "events"),
Output("add_modal", "opened", allow_duplicate=True),
Output("event_name_input", "value"),
Output("event_color_select", "value"),
Output("rich_text_input", "value"),
Input("modal_submit_new_event_button", "n_clicks"),
State("start_date", "value"),
State("start_time", "value"),
State("end_date", "value"),
State("end_time", "value"),
State("event_name_input", "value"),
State("event_color_select", "value"),
State("rich_text_output", "children"),
State("calendar", "events"),
)
def add_new_event(
n,
start_date,
start_time,
end_date,
end_time,
event_name,
event_color,
event_context,
current_events,
):
if n is None:
raise PreventUpdate
start_time_obj = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S")
end_time_obj = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S")
start_time_str = start_time_obj.strftime("%H:%M:%S")
end_time_str = end_time_obj.strftime("%H:%M:%S")
start_date = f"{start_date}T{start_time_str}"
end_date = f"{end_date}T{end_time_str}"
new_event = {
"title": event_name,
"start": start_date,
"end": end_date,
"className": event_color,
"context": event_context,
}
return current_events + [new_event], False, "", "bg-gradient-primary", ""
@app.callback(
Output("rich_text_output", "children"),
[Input("rich_text_input", "value")],
[State("rich_text_input", "charCount")],
)
def display_output(value, charCount):
return value
if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True, port=8050)

View File

@@ -0,0 +1,83 @@
"""
Collapsible navbar on both desktop and mobile
"""
import dash_mantine_components as dmc
from dash import Dash, Input, Output, State, callback
from dash_iconify import DashIconify
app = Dash(external_stylesheets=dmc.styles.ALL)
logo = "https://github.com/user-attachments/assets/c1ff143b-4365-4fd1-880f-3e97aab5c302"
def get_icon(icon):
return DashIconify(icon=icon, height=16)
layout = dmc.AppShell(
[
dmc.AppShellHeader(
dmc.Group(
[
dmc.Burger(
id="mobile-burger",
size="sm",
hiddenFrom="sm",
opened=False,
),
dmc.Burger(
id="desktop-burger",
size="sm",
visibleFrom="sm",
opened=True,
),
dmc.Image(src=logo, h=40),
dmc.Title("Demo App", c="blue"),
],
h="100%",
px="md",
)
),
dmc.AppShellNavbar(
id="navbar",
children=[
"Navbar",
dmc.NavLink(
label="With icon",
leftSection=get_icon(icon="bi:house-door-fill"),
),
],
p="md",
),
dmc.AppShellMain("Main"),
],
header={"height": 60},
navbar={
"width": 300,
"breakpoint": "sm",
"collapsed": {"mobile": True, "desktop": False},
},
padding="md",
id="appshell",
)
app.layout = dmc.MantineProvider(layout)
@callback(
Output("appshell", "navbar"),
Input("mobile-burger", "opened"),
Input("desktop-burger", "opened"),
State("appshell", "navbar"),
)
def toggle_navbar(mobile_opened, desktop_opened, navbar):
navbar["collapsed"] = {
"mobile": not mobile_opened,
"desktop": not desktop_opened,
}
return navbar
if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True, port=8050)

View File

@@ -0,0 +1,16 @@
# dashboard/pages/appointments.py
from dash import html
import dash
dash.register_page(__name__, path="/appointments", name="Termine")
layout = html.Div(
className="appointments-page",
children=[
html.H3("Terminverwaltung"),
html.Div(id="calendar-container"),
html.Button("Neuen Termin anlegen", id="btn-add-appointment"),
html.Div(id="appointments-feedback"),
# Hier später das Modal oder das FullCalendarElement einfügen
]
)

16
dashboard/pages/login.py Normal file
View File

@@ -0,0 +1,16 @@
# dashboard/pages/login.py
from dash import html, dcc
import dash
dash.register_page(__name__, path="/login", name="Login")
layout = html.Div(
className="login-page",
children=[
html.H2("Bitte einloggen"),
dcc.Input(id="input-user", type="text", placeholder="Benutzername"),
dcc.Input(id="input-pass", type="password", placeholder="Passwort"),
html.Button("Einloggen", id="btn-login"),
html.Div(id="login-feedback", className="text-danger")
]
)

View File

@@ -0,0 +1,13 @@
# dashboard/pages/overview.py
from dash import html, dcc
import dash
dash.register_page(__name__, path="/overview", name="Übersicht")
layout = html.Div(
className="overview-page",
children=[
dcc.Interval(id="interval-update", interval=10_000, n_intervals=0),
html.Div(id="clients-cards-container")
]
)

View File

@@ -0,0 +1,13 @@
# dashboard/pages/settings.py
from dash import html
import dash
dash.register_page(__name__, path="/settings", name="Einstellungen")
layout = html.Div(
className="settings-page",
children=[
html.H3("Allgemeine Einstellungen"),
# Formularfelder / Tabs für globale Optionen
]
)

15
dashboard/pages/users.py Normal file
View File

@@ -0,0 +1,15 @@
# dashboard/pages/users.py
from dash import html, dash_table, dcc
import dash
dash.register_page(__name__, path="/users", name="Benutzer")
layout = html.Div(
className="users-page",
children=[
html.H3("Benutzerverwaltung"),
html.Button("Neuen Benutzer anlegen", id="btn-new-user"),
html.Div(id="users-table-container"),
html.Div(id="users-feedback")
]
)

View File

@@ -0,0 +1,10 @@
bcrypt>=4.3.0
dash>=3.0.4
dash-bootstrap-components>=2.0.3
dash_iconify>=0.1.2
dash_mantine_components>=1.2.0
dash-quill>=0.0.4
full-calendar-component>=0.0.4
pandas>=2.2.3
paho-mqtt>=2.1.0
python-dotenv>=1.1.0

View File

12
dashboard/utils/auth.py Normal file
View File

@@ -0,0 +1,12 @@
# dashboard/utils/auth.py
import bcrypt
def hash_password(plain_text: str) -> str:
return bcrypt.hashpw(plain_text.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def check_password(plain_text: str, hashed: str) -> bool:
return bcrypt.checkpw(plain_text.encode("utf-8"), hashed.encode("utf-8"))
def get_user_role(username: str) -> str:
# Beispiel: aus der Datenbank auslesen (oder Hardcode während Dev-Phase)
pass

46
dashboard/utils/db.py Normal file
View File

@@ -0,0 +1,46 @@
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# .env laden
load_dotenv()
# Datenbank-Zugangsdaten aus .env
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_NAME = os.getenv("DB_NAME")
# Pooling Parameter aus .env (optional mit Default-Werten)
POOL_SIZE = int(os.getenv("POOL_SIZE", 10))
MAX_OVERFLOW = int(os.getenv("MAX_OVERFLOW", 20))
POOL_TIMEOUT = int(os.getenv("POOL_TIMEOUT", 30))
POOL_RECYCLE = int(os.getenv("POOL_RECYCLE", 1800))
# Connection-String zusammenbauen
DATABASE_URL = (
f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
)
# Engine mit Pooling konfigurieren
engine = create_engine(
DATABASE_URL,
pool_size=POOL_SIZE,
max_overflow=MAX_OVERFLOW,
pool_timeout=POOL_TIMEOUT,
pool_recycle=POOL_RECYCLE,
echo=True, # für Debug, später False
)
# Session Factory
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_session():
return SessionLocal()
def execute_query(query):
with engine.connect() as connection:
result = connection.execute(text(query))
return [dict(row) for row in result]

View File

@@ -0,0 +1,121 @@
# dashboard/utils/mqtt_client.py
import os
import threading
import time
from dotenv import load_dotenv
import paho.mqtt.client as mqtt
# 1. Laden der Umgebungsvariablen aus .env
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env"))
# 2. Lese MQTTEinstellungen
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST", "localhost")
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
MQTT_USERNAME = os.getenv("MQTT_USERNAME", None)
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", None)
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"dash-{int(time.time())}")
# 3. Erstelle eine globale ClientInstanz
client = mqtt.Client(client_id=MQTT_CLIENT_ID)
# Falls Nutzer/Passwort gesetzt sind, authentifizieren
if MQTT_USERNAME and MQTT_PASSWORD:
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
# 4. CallbackStubs (kannst du bei Bedarf anpassen)
def _on_connect(client, userdata, flags, rc):
if rc == 0:
print(f"[mqtt_client.py] Erfolgreich mit MQTTBroker verbunden (Code {rc})")
else:
print(f"[mqtt_client.py] Verbindungsfehler, rc={rc}")
def _on_disconnect(client, userdata, rc):
print(f"[mqtt_client.py] Verbindung getrennt (rc={rc}). Versuche, neu zu verbinden …")
def _on_message(client, userdata, msg):
"""
Diese CallbackFunktion wird aufgerufen, sobald eine Nachricht auf einem
Topic ankommt, auf das wir subscribed haben. Du kannst hier eine Queue
füllen oder direkt eine DatenbankFunktion aufrufen.
"""
topic = msg.topic
payload = msg.payload.decode("utf-8", errors="ignore")
print(f"[mqtt_client.py] Nachricht eingegangen Topic: {topic}, Payload: {payload}")
# Beispiel: Wenn du LiveStatusdaten in die Datenbank schreibst,
# könntest du hier utils/db.execute_non_query(...) aufrufen.
# 5. Setze die Callbacks
client.on_connect = _on_connect
client.on_disconnect = _on_disconnect
client.on_message = _on_message
def start_loop():
"""
Startet die EndlosSchleife, in der der Client auf eingehende
MQTTNachrichten hört und automatisch reconnectet.
Muss idealerweise in einem eigenen Thread laufen, damit DashCallbacks
nicht blockieren.
"""
try:
client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, keepalive=MQTT_KEEPALIVE)
client.loop_start()
except Exception as e:
print(f"[mqtt_client.py] Konnte keine Verbindung zum MQTTBroker herstellen: {e}")
def stop_loop():
"""
Stoppt die MQTTLoop und trennt die Verbindung.
"""
try:
client.loop_stop()
client.disconnect()
except Exception as e:
print(f"[mqtt_client.py] Fehler beim Stoppen der MQTTSchleife: {e}")
def publish(topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
"""
Verschickt eine MQTTNachricht:
- topic: z. B. "clients/{client_id}/control"
- payload: z. B. '{"command":"restart"}'
- qos: 0, 1 oder 2
- retain: True/False
Rückgabe: True, falls Veröffentlichung bestätigt wurde; sonst False.
"""
try:
result = client.publish(topic, payload, qos=qos, retain=retain)
status = result.rc # 0=Erfolg, sonst Fehler
if status == mqtt.MQTT_ERR_SUCCESS:
return True
else:
print(f"[mqtt_client.py] Publish-Fehler für Topic {topic}, rc={status}")
return False
except Exception as e:
print(f"[mqtt_client.py] Exception beim Publish: {e}")
return False
def subscribe(topic: str, qos: int = 0) -> bool:
"""
Abonniert ein MQTTTopic, sodass _on_message gerufen wird, sobald Nachrichten
ankommen.
Rückgabe: True bei Erfolg, ansonsten False.
"""
try:
result, mid = client.subscribe(topic, qos=qos)
if result == mqtt.MQTT_ERR_SUCCESS:
return True
else:
print(f"[mqtt_client.py] SubscribeFehler für Topic {topic}, rc={result}")
return False
except Exception as e:
print(f"[mqtt_client.py] Exception beim Subscribe: {e}")
return False