diff --git a/app.py b/app.py new file mode 100644 index 0000000..3e6dbf3 --- /dev/null +++ b/app.py @@ -0,0 +1,644 @@ +""" +ITSM-Plattform -- eigenstaendiges Produkt (nicht Teil von AES). + +Architektur-Entscheidung (2026-07-13, Nutzer-Vorgabe): "Es soll ein ITSM werden, +mit AES als 'buchbares' Modul." D.h. dieses Repo/Deployment ist NICHT eine +Erweiterung des AES-Dashboards, sondern ein eigenes System mit Service-Katalog. +AES ist darin nur der erste eingetragene, buchbare Service -- integriert ueber +die bestehende AES-Dashboard-HTTP-API (Dispatch, Projekt-Status), nicht per +Code-Import. Weitere Services (Netzwerk, Cloud, Security, ...) koennen spaeter +denselben Mechanismus nutzen. + +Persistenz: einfache JSON-Dateien unter DATA_DIR (kein Datenbankserver noetig, +gleiches Prinzip wie im AES-Dashboard). Ein Prozess, ein Worker -- Threading-Lock +schuetzt gegen Nebenlaeufigkeit bei mehreren Gunicorn-Threads. + +ENV: + ITSM_DATA_DIR Verzeichnis fuer tickets.json/services.json/users.json + ITSM_USER / ITSM_PASSWORD / ITSM_SECRET_KEY Login + Session-Secret + AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration +""" +import os +import json +import html +import time +import uuid +import secrets +import hashlib +import threading +import urllib.request +import urllib.error +import datetime as _dt + +from flask import Flask, request, redirect, url_for, session, jsonify, abort + +DATA_DIR = os.getenv("ITSM_DATA_DIR", "/data") +ITSM_USER = os.getenv("ITSM_USER", "admin") +ITSM_PASSWORD = os.getenv("ITSM_PASSWORD", "") +AES_DASHBOARD_URL = os.getenv("AES_DASHBOARD_URL", "").rstrip("/") + +app = Flask(__name__) +app.secret_key = os.getenv("ITSM_SECRET_KEY") or secrets.token_hex(32) + +_LOCK = threading.Lock() + +CATEGORIES = ["Incident", "Problem", "Task", "Change", "Release"] +PRIORITIES = ["Niedrig", "Mittel", "Hoch", "Kritisch"] +STATUSES = ["Offen", "In Bearbeitung", "Warten", "Geloest", "Geschlossen"] + + +# ── Persistenz ──────────────────────────────────────────────────────────────── +def _path(name): + return os.path.join(DATA_DIR, name) + + +def _load(name, default): + p = _path(name) + if not os.path.isfile(p): + return default + try: + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return default + + +def _save(name, data): + os.makedirs(DATA_DIR, exist_ok=True) + p = _path(name) + tmp = p + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, p) + + +def _now(): + return _dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + + +def _next_ticket_id(tickets): + year = _dt.datetime.utcnow().year + seq = 1 + prefix = "TKT-%d-" % year + existing = [t["id"] for t in tickets if t["id"].startswith(prefix)] + if existing: + nums = [int(i.split("-")[-1]) for i in existing] + seq = max(nums) + 1 + return "%s%06d" % (prefix, seq) + + +def _seed_if_empty(): + with _LOCK: + tickets = _load("tickets.json", []) + if tickets: + return + services = _load("services.json", []) + service_names = [s["name"] for s in services] or ["Allgemein"] + seed = [ + { + "id": "TKT-%d-000001" % _dt.datetime.utcnow().year, + "titel": "Willkommen im ITSM", + "beschreibung": ( + "Dies ist ein Beispiel-Ticket. Ueber 'Neues Ticket' oben rechts " + "kannst du echte Vorgaenge anlegen. Tickets koennen optional " + "einem Service aus dem Katalog zugeordnet werden (z. B. AES)." + ), + "service": service_names[0], + "status": "Offen", + "prioritaet": "Niedrig", + "kategorie": "Task", + "zugewiesen_an": ITSM_USER, + "erstellt": _now(), + "aktualisiert": _now(), + "fortschritt": 0, + "betroffene_services": [], + "ci_elemente": [], + "sla_antwort_minuten": 480, + "sla_loesung_minuten": 2880, + "timeline": [{"zeit": _now(), "akteur": "System", "text": "Ticket angelegt."}], + } + ] + _save("tickets.json", seed) + + +def _seed_services_if_empty(): + with _LOCK: + services = _load("services.json", []) + if services: + return + services = [ + { + "id": "aes", + "name": "AES -- Autonomous Engineering System", + "beschreibung": ( + "Automatisierte Software-Entwicklung: Projekte, Phasen, " + "LLM-gestuetzte Agenten, Auto-Fix und Release-Pipeline." + ), + "kategorie": "Entwicklung", + "gebucht": True, + "endpoint": AES_DASHBOARD_URL, + "hinzugefuegt": _now(), + } + ] + _save("services.json", services) + + +# ── Auth ────────────────────────────────────────────────────────────────────── +def _check_login(user, pw): + return user == ITSM_USER and ITSM_PASSWORD and pw == ITSM_PASSWORD + + +def _require_login(): + if not session.get("user"): + return redirect(url_for("login", next=request.path)) + return None + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + error = None + if request.method == "POST": + u = request.form.get("username", "") + p = request.form.get("password", "") + if _check_login(u, p): + session["user"] = u + return redirect(request.args.get("next") or url_for("tickets_route")) + error = "Benutzername oder Passwort falsch." + err_html = "

%s

" % html.escape(error) if error else "" + return """ + +Anmelden -- ITSM%s +
+

ITSM

IT Service Management
+%s + + + +
""" % (_CSS_TAG, err_html) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ── CSS (eigenstaendig, an AES-Farbschema angelehnt aber neu) ───────────────── +_CSS = """ +:root{--bg:#161d2b;--panel:#1e2738;--panel2:#232e42;--border:#324259;--text:#e6edf5; +--sub:#93a3b8;--accent:#4c9eba;--accent2:#5db3d0;--ok:#3ecf8e;--warn:#e0a83e; +--bad:#e5534b;--crit:#c9364a;} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px} +a{color:var(--accent2);text-decoration:none} +a:hover{text-decoration:underline} +.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center} +.login-box{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:28px;width:300px} +.login-box h1{margin:0 0 4px;font-size:22px} +.login-box input{width:100%;padding:9px 10px;margin-bottom:10px;background:var(--bg); + border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px} +.btn{background:var(--accent);border:none;color:#0c1420;font-weight:600;padding:9px 14px; + border-radius:6px;cursor:pointer;font-size:13px} +.btn:hover{background:var(--accent2)} +.btn.ghost{background:transparent;border:1px solid var(--border);color:var(--text)} +.sz{color:var(--sub);font-size:12px} +.shell{display:flex;min-height:100vh} +.sidebar{width:220px;flex:0 0 220px;background:var(--panel);border-right:1px solid var(--border);padding:16px 0;overflow-y:auto} +.brand{padding:0 16px 16px;font-weight:700;font-size:16px} +.brand .sz{font-weight:400} +.navgroup{margin-top:14px} +.navgroup h4{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--sub); + padding:0 16px;margin:0 0 4px} +.navgroup a{display:block;padding:6px 16px;color:var(--text);font-size:13px} +.navgroup a:hover{background:var(--panel2);text-decoration:none} +.navgroup a.active{background:var(--panel2);border-left:2px solid var(--accent);color:var(--accent2)} +.main{flex:1;min-width:0} +.topbar{display:flex;align-items:center;justify-content:space-between;padding:12px 24px; + border-bottom:1px solid var(--border);background:var(--panel)} +.topbar .search{flex:1;max-width:420px;background:var(--bg);border:1px solid var(--border); + border-radius:6px;padding:7px 12px;color:var(--sub);font-size:13px} +.content{padding:24px} +h1.page-title{font-size:22px;margin:0 0 18px} +.kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px} +.kpi{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px 16px} +.kpi .lbl{color:var(--sub);font-size:12px;margin-bottom:6px} +.kpi .val{font-size:26px;font-weight:700} +.kpi .trend{font-size:11px;margin-top:4px} +.kpi .trend.up{color:var(--ok)} .kpi .trend.down{color:var(--bad)} +.tabs{display:flex;gap:4px;margin-bottom:14px;flex-wrap:wrap} +.tabs a{padding:6px 12px;border-radius:6px;font-size:13px;color:var(--sub);border:1px solid transparent} +.tabs a.active{background:var(--panel2);color:var(--text);border-color:var(--border)} +table.tickets{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:8px;overflow:hidden} +table.tickets th{text-align:left;font-size:11px;text-transform:uppercase;color:var(--sub); + padding:10px 12px;border-bottom:1px solid var(--border);background:var(--panel2)} +table.tickets td{padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:top} +table.tickets tr:last-child td{border-bottom:none} +table.tickets tr.row{cursor:pointer} +table.tickets tr.row:hover{background:var(--panel2)} +.pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:11px;font-weight:600} +.pill.offen{background:#2e3a52;color:#9fb3ce} +.pill.bearbeitung{background:#4a3a1a;color:var(--warn)} +.pill.warten{background:#3a2f52;color:#b79ee0} +.pill.geloest{background:#1a4a35;color:var(--ok)} +.pill.geschlossen{background:#2a2a2a;color:#888} +.pill.ueberfaellig{background:#4a1e22;color:var(--bad)} +.pill.krit{color:var(--crit)} .pill.hoch{color:var(--bad)} .pill.mittel{color:var(--warn)} .pill.niedrig{color:var(--sub)} +.bar-track{background:#2a3446;border-radius:99px;height:6px;width:90px;display:inline-block;vertical-align:middle} +.bar-fill{background:var(--accent);height:6px;border-radius:99px} +.panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:18px;margin-bottom:16px} +.card{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:12px} +.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px} +.svc-card{display:flex;justify-content:space-between;align-items:flex-start} +.svc-card .badge{padding:2px 9px;border-radius:99px;font-size:11px;background:#1a4a35;color:var(--ok)} +input[type=text],textarea,select{background:var(--bg);border:1px solid var(--border);border-radius:6px; + color:var(--text);padding:8px 10px;font-size:13px;width:100%} +textarea{min-height:70px;font-family:inherit} +.formrow{margin-bottom:10px} +#detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;z-index:40} +#detail-panel{position:fixed;right:0;top:0;bottom:0;width:420px;background:var(--panel); + border-left:1px solid var(--border);z-index:41;transform:translateX(100%); + transition:transform .18s ease;overflow-y:auto;padding:20px} +#detail-panel.open{transform:translateX(0)} +#detail-overlay.open{display:block} +.dt-tabs{display:flex;gap:14px;border-bottom:1px solid var(--border);margin:14px 0} +.dt-tabs span{padding-bottom:8px;color:var(--sub);font-size:13px;cursor:pointer} +.dt-tabs span.active{color:var(--accent2);border-bottom:2px solid var(--accent2)} +.tl-item{border-left:2px solid var(--border);padding-left:12px;margin-bottom:12px;position:relative} +.tl-item::before{content:'';position:absolute;left:-5px;top:2px;width:8px;height:8px;border-radius:50%;background:var(--accent)} +""" +_CSS_TAG = "" + +_NAV = [ + ("Service Management", [ + ("/tickets", "Tickets"), + ("/probleme", "Probleme"), + ("/aenderungen", "Aenderungen"), + ("/releases", "Releases"), + ]), + ("Services", [ + ("/services", "Service-Katalog"), + ]), + ("Wissen", [ + ("/wissen", "Wissensdatenbank"), + ]), + ("Assets", [ + ("/assets", "Asset-Liste"), + ]), + ("Administration", [ + ("/admin", "Einstellungen"), + ]), +] + + +def _shell(active_path, title, body_html, extra_head=""): + groups_html = [] + for group_name, items in _NAV: + links = "".join( + '%s' % (href, "active" if href == active_path else "", html.escape(label)) + for href, label in items + ) + groups_html.append('' % (html.escape(group_name), links)) + nav_html = "".join(groups_html) + return """ + +%s -- ITSM%s%s +
+ +
+
+ +
%s · Abmelden
+
+
%s
+
+
+
+
+""" % (html.escape(title), _CSS_TAG, extra_head, nav_html, html.escape(session.get("user", "")), body_html) + + +# ── Tickets: Berechnung + Rendering ─────────────────────────────────────────── +def _is_overdue(t): + if t["status"] in ("Geloest", "Geschlossen"): + return False + try: + created = _dt.datetime.fromisoformat(t["erstellt"].replace("Z", "+00:00")) + except Exception: + return False + age_min = (_dt.datetime.now(_dt.timezone.utc) - created).total_seconds() / 60 + return age_min > t.get("sla_loesung_minuten", 999999) + + +def _kpis(tickets): + total = len(tickets) + offen = sum(1 for t in tickets if t["status"] == "Offen") + bearbeitung = sum(1 for t in tickets if t["status"] == "In Bearbeitung") + warten = sum(1 for t in tickets if t["status"] == "Warten") + ueberfaellig = sum(1 for t in tickets if _is_overdue(t)) + now = _dt.datetime.now(_dt.timezone.utc) + geloest_7d = 0 + for t in tickets: + if t["status"] in ("Geloest", "Geschlossen"): + try: + upd = _dt.datetime.fromisoformat(t["aktualisiert"].replace("Z", "+00:00")) + if (now - upd).days <= 7: + geloest_7d += 1 + except Exception: + pass + return { + "Gesamt": total, "Offen": offen, "In Bearbeitung": bearbeitung, + "Warten auf Input": warten, "Ueberfaellig": ueberfaellig, + "Geloest (7 Tage)": geloest_7d, + } + + +def _status_class(s): + return {"Offen": "offen", "In Bearbeitung": "bearbeitung", "Warten": "warten", + "Geloest": "geloest", "Geschlossen": "geschlossen"}.get(s, "offen") + + +def _prio_class(p): + return {"Kritisch": "krit", "Hoch": "hoch", "Mittel": "mittel", "Niedrig": "niedrig"}.get(p, "mittel") + + +def _ticket_row(t): + status = "ueberfaellig" if _is_overdue(t) else _status_class(t["status"]) + status_label = "Ueberfaellig" if _is_overdue(t) else t["status"] + return """ + %s + %s
%s
+ %s + %s + %s + %s + %s +
%s%% +""" % ( + t["id"], t["id"], t["id"], html.escape(t["titel"]), html.escape(t.get("service", "")), + status, status_label, _prio_class(t["prioritaet"]), t["prioritaet"], t["kategorie"], + html.escape(t["zugewiesen_an"]), t["aktualisiert"][:16].replace("T", " "), + t["fortschritt"], t["fortschritt"]) + + +def _kpi_tile(label, value): + return '
%s
%s
' % (html.escape(label), value) + + +def _tickets_body(filter_status=None, filter_kategorie=None): + tickets = sorted(_load("tickets.json", []), key=lambda t: t["aktualisiert"], reverse=True) + kpis = _kpis(tickets) + kpi_html = "".join(_kpi_tile(k, v) for k, v in kpis.items()) + shown = tickets + if filter_kategorie: + shown = [t for t in shown if t["kategorie"] == filter_kategorie] + tabs = [("", "Alle"), ("Offen", "Offen"), ("In Bearbeitung", "In Bearbeitung"), + ("Warten", "Warten"), ("Geloest", "Geloest"), ("Ueberfaellig", "Ueberfaellig")] + base = "/probleme" if filter_kategorie == "Problem" else ( + "/aenderungen" if filter_kategorie == "Change" else ( + "/releases" if filter_kategorie == "Release" else "/tickets")) + tabs_html = "".join( + '%s' % ( + base, ("?status=" + s) if s else "", "active" if (s or "") == (filter_status or "") else "", label) + for s, label in tabs) + if filter_status == "Ueberfaellig": + shown = [t for t in shown if _is_overdue(t)] + elif filter_status: + shown = [t for t in shown if t["status"] == filter_status] + rows = "".join(_ticket_row(t) for t in shown) or 'Keine Tickets.' + new_form = """

Neues Ticket

+
+
+
+
+
+
+
+
+ +
""" % ( + "".join('' % (c, c) for c in CATEGORIES), + "".join('' % (p, p) for p in PRIORITIES)) + return """

Tickets

+
%s
+%s +
%s
+ + + +%s
Ticket-NrTitel (Service)StatusPrioritaetKategorieZugewiesen anAktualisiertFortschritt
+""" % (kpi_html, new_form, tabs_html, rows) + + +@app.route("/tickets") +def tickets_route(): + r = _require_login() + if r: + return r + return _shell("/tickets", "Tickets", _tickets_body(filter_status=request.args.get("status"))) + + +@app.route("/probleme") +def probleme_route(): + r = _require_login() + if r: + return r + return _shell("/probleme", "Probleme", _tickets_body(request.args.get("status"), "Problem")) + + +@app.route("/aenderungen") +def aenderungen_route(): + r = _require_login() + if r: + return r + return _shell("/aenderungen", "Aenderungen", _tickets_body(request.args.get("status"), "Change")) + + +@app.route("/releases") +def releases_route(): + r = _require_login() + if r: + return r + return _shell("/releases", "Releases", _tickets_body(request.args.get("status"), "Release")) + + +@app.route("/tickets/new", methods=["POST"]) +def tickets_new_route(): + r = _require_login() + if r: + return r + with _LOCK: + tickets = _load("tickets.json", []) + tid = _next_ticket_id(tickets) + t = { + "id": tid, + "titel": request.form.get("titel", "").strip() or "(ohne Titel)", + "beschreibung": request.form.get("beschreibung", "").strip(), + "service": request.form.get("service", "").strip(), + "status": "Offen", + "prioritaet": request.form.get("prioritaet", "Mittel"), + "kategorie": request.form.get("kategorie", "Task"), + "zugewiesen_an": session.get("user", ITSM_USER), + "erstellt": _now(), + "aktualisiert": _now(), + "fortschritt": 0, + "betroffene_services": [], + "ci_elemente": [], + "sla_antwort_minuten": 480, + "sla_loesung_minuten": 2880, + "timeline": [{"zeit": _now(), "akteur": session.get("user", ITSM_USER), "text": "Ticket angelegt."}], + } + tickets.append(t) + _save("tickets.json", tickets) + return redirect(url_for("tickets_route")) + + +@app.route("/api/tickets/") +def api_ticket(tid): + r = _require_login() + if r: + return r + tickets = _load("tickets.json", []) + for t in tickets: + if t["id"] == tid: + return jsonify(t) + abort(404) + + +# ── Service-Katalog ──────────────────────────────────────────────────────────── +def _aes_live_status(): + """Fragt den Status des AES-Service live ueber dessen Dashboard-API ab + (kein Code-Import -- ITSM und AES sind getrennte Systeme, verbunden nur + per HTTP, wie es die Rolle 'AES als buchbarer Service' verlangt).""" + if not AES_DASHBOARD_URL: + return None + try: + req = urllib.request.Request(AES_DASHBOARD_URL + "/health", method="GET") + with urllib.request.urlopen(req, timeout=4) as resp: + return resp.status == 200 + except Exception: + return None + + +def _services_body(): + services = _load("services.json", []) + cards = [] + for s in services: + badge = 'gebucht' if s.get("gebucht") else 'nicht gebucht' + link = ('
Zum Service →
' + % html.escape(s["endpoint"])) if s.get("endpoint") else "" + cards.append("""
+
%s
%s
%s
%s
""" % ( + html.escape(s["name"]), html.escape(s["beschreibung"]), badge, link)) + add_form = """

Service hinzufuegen

+
+
+
+
+ +
""" + return '

Service-Katalog

' + "".join(cards) + add_form + + +@app.route("/services") +def services_route(): + r = _require_login() + if r: + return r + return _shell("/services", "Service-Katalog", _services_body()) + + +@app.route("/services/new", methods=["POST"]) +def services_new_route(): + r = _require_login() + if r: + return r + with _LOCK: + services = _load("services.json", []) + services.append({ + "id": uuid.uuid4().hex[:8], + "name": request.form.get("name", "").strip(), + "beschreibung": request.form.get("beschreibung", "").strip(), + "kategorie": "Sonstiges", + "gebucht": True, + "endpoint": request.form.get("endpoint", "").strip(), + "hinzugefuegt": _now(), + }) + _save("services.json", services) + return redirect(url_for("services_route")) + + +# ── Platzhalterseiten (Wissen/Assets/Administration) ────────────────────────── +def _placeholder(title, note): + return '

%s

%s

' % ( + html.escape(title), html.escape(note)) + + +@app.route("/wissen") +def wissen_route(): + r = _require_login() + if r: + return r + return _shell("/wissen", "Wissensdatenbank", + _placeholder("Wissensdatenbank", "Noch nicht aufgebaut -- folgt in einer naechsten Ausbaustufe.")) + + +@app.route("/assets") +def assets_route(): + r = _require_login() + if r: + return r + return _shell("/assets", "Asset-Liste", + _placeholder("Asset-Liste", "Noch nicht aufgebaut -- folgt in einer naechsten Ausbaustufe.")) + + +@app.route("/admin") +def admin_route(): + r = _require_login() + if r: + return r + return _shell("/admin", "Einstellungen", + _placeholder("Einstellungen", "Benutzer/Rollen/Workflows folgen in einer naechsten Ausbaustufe.")) + + +@app.route("/") +def root_route(): + return redirect(url_for("tickets_route")) + + +@app.route("/health") +def health_route(): + return jsonify({"status": "ok"}) + + +_seed_services_if_empty() +_seed_if_empty() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080)