645 lines
27 KiB
Python
645 lines
27 KiB
Python
"""
|
|
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 = "<p style='color:#E5534B;font-size:13px;margin:0 0 10px'>%s</p>" % html.escape(error) if error else ""
|
|
return """<!doctype html><html lang="de"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Anmelden -- ITSM</title>%s</head><body>
|
|
<div class="login-wrap"><form class="login-box" method="post">
|
|
<h1>ITSM</h1><div class="sz" style="margin-bottom:14px">IT Service Management</div>
|
|
%s
|
|
<input name="username" placeholder="Benutzername" autofocus>
|
|
<input name="password" type="password" placeholder="Passwort">
|
|
<button class="btn" type="submit">Anmelden</button>
|
|
</form></div></body></html>""" % (_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 = "<style>" + _CSS + "</style>"
|
|
|
|
_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(
|
|
'<a href="%s" class="%s">%s</a>' % (href, "active" if href == active_path else "", html.escape(label))
|
|
for href, label in items
|
|
)
|
|
groups_html.append('<div class="navgroup"><h4>%s</h4>%s</div>' % (html.escape(group_name), links))
|
|
nav_html = "".join(groups_html)
|
|
return """<!doctype html><html lang="de"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>%s -- ITSM</title>%s%s</head><body>
|
|
<div class="shell">
|
|
<div class="sidebar"><div class="brand">ITSM<div class="sz">IT Service Management</div></div>%s</div>
|
|
<div class="main">
|
|
<div class="topbar">
|
|
<input class="search" placeholder="Suche (Tickets, Services, Wissen ...)">
|
|
<div class="sz">%s · <a href="/logout">Abmelden</a></div>
|
|
</div>
|
|
<div class="content">%s</div>
|
|
</div>
|
|
</div>
|
|
<div id="detail-overlay" onclick="closeDetail()"></div>
|
|
<div id="detail-panel"></div>
|
|
</body></html>""" % (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 """<tr class="row" onclick="openDetail('%s')">
|
|
<td><a href="#" onclick="event.stopPropagation();openDetail('%s');return false">%s</a></td>
|
|
<td><b>%s</b><div class="sz">%s</div></td>
|
|
<td><span class="pill %s">%s</span></td>
|
|
<td><span class="pill %s">%s</span></td>
|
|
<td>%s</td>
|
|
<td>%s</td>
|
|
<td class="sz">%s</td>
|
|
<td><div class="bar-track"><div class="bar-fill" style="width:%s%%"></div></div> %s%%</td>
|
|
</tr>""" % (
|
|
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 '<div class="kpi"><div class="lbl">%s</div><div class="val">%s</div></div>' % (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(
|
|
'<a href="%s%s" class="%s">%s</a>' % (
|
|
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 '<tr><td colspan="8" class="sz">Keine Tickets.</td></tr>'
|
|
new_form = """<div class="panel"><h3 style="margin-top:0">Neues Ticket</h3>
|
|
<form method="post" action="/tickets/new">
|
|
<div class="formrow"><input type="text" name="titel" placeholder="Titel" required></div>
|
|
<div class="formrow"><textarea name="beschreibung" placeholder="Beschreibung"></textarea></div>
|
|
<div class="grid-2">
|
|
<div class="formrow"><select name="kategorie">%s</select></div>
|
|
<div class="formrow"><select name="prioritaet">%s</select></div>
|
|
</div>
|
|
<div class="formrow"><input type="text" name="service" placeholder="Service (optional, z. B. AES)"></div>
|
|
<button class="btn" type="submit">Anlegen</button>
|
|
</form></div>""" % (
|
|
"".join('<option value="%s">%s</option>' % (c, c) for c in CATEGORIES),
|
|
"".join('<option value="%s">%s</option>' % (p, p) for p in PRIORITIES))
|
|
return """<h1 class="page-title">Tickets</h1>
|
|
<div class="kpi-row">%s</div>
|
|
%s
|
|
<div class="tabs">%s</div>
|
|
<table class="tickets"><thead><tr>
|
|
<th>Ticket-Nr</th><th>Titel (Service)</th><th>Status</th><th>Prioritaet</th>
|
|
<th>Kategorie</th><th>Zugewiesen an</th><th>Aktualisiert</th><th>Fortschritt</th>
|
|
</tr></thead><tbody>%s</tbody></table>
|
|
<script>
|
|
function openDetail(id){
|
|
fetch('/api/tickets/' + id).then(r => r.json()).then(t => {
|
|
document.getElementById('detail-panel').innerHTML = renderDetail(t);
|
|
document.getElementById('detail-panel').classList.add('open');
|
|
document.getElementById('detail-overlay').classList.add('open');
|
|
});
|
|
}
|
|
function closeDetail(){
|
|
document.getElementById('detail-panel').classList.remove('open');
|
|
document.getElementById('detail-overlay').classList.remove('open');
|
|
}
|
|
function renderDetail(t){
|
|
var tl = (t.timeline||[]).slice().reverse().map(function(e){
|
|
return '<div class="tl-item"><b>'+e.akteur+'</b><div class="sz">'+e.zeit.replace('T',' ').slice(0,16)+'</div><div>'+e.text+'</div></div>';
|
|
}).join('') || '<div class="sz">Keine Ereignisse.</div>';
|
|
return '<h2 style="margin-top:0">'+t.id+'</h2>'
|
|
+ '<div class="sz" style="margin-bottom:10px">'+t.titel+'</div>'
|
|
+ '<div><span class="pill '+(t.status||'').toLowerCase()+'">'+t.status+'</span> '
|
|
+ '<span class="pill">'+t.prioritaet+'</span> <span class="pill">'+t.kategorie+'</span></div>'
|
|
+ '<div class="dt-tabs"><span class="active">Details</span><span>Aktivitaeten</span><span>Anhaenge</span><span>Aufgaben</span></div>'
|
|
+ '<div class="sz" style="margin-bottom:6px">Beschreibung</div><div style="margin-bottom:14px">'+(t.beschreibung||'')+'</div>'
|
|
+ '<div class="sz" style="margin-bottom:6px">Service</div><div style="margin-bottom:14px">'+(t.service||'(kein Service)')+'</div>'
|
|
+ '<div class="sz" style="margin-bottom:6px">Zeitleiste</div>'+tl
|
|
+ '<div style="margin-top:16px"><button class="btn ghost" onclick="closeDetail()">Schliessen</button></div>';
|
|
}
|
|
</script>""" % (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/<tid>")
|
|
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 = '<span class="badge">gebucht</span>' if s.get("gebucht") else '<span class="sz">nicht gebucht</span>'
|
|
link = ('<div class="sz" style="margin-top:8px"><a href="%s" target="_blank">Zum Service →</a></div>'
|
|
% html.escape(s["endpoint"])) if s.get("endpoint") else ""
|
|
cards.append("""<div class="card"><div class="svc-card">
|
|
<div><b>%s</b><div class="sz" style="margin-top:4px">%s</div></div>%s</div>%s</div>""" % (
|
|
html.escape(s["name"]), html.escape(s["beschreibung"]), badge, link))
|
|
add_form = """<div class="panel"><h3 style="margin-top:0">Service hinzufuegen</h3>
|
|
<form method="post" action="/services/new">
|
|
<div class="formrow"><input type="text" name="name" placeholder="Name" required></div>
|
|
<div class="formrow"><textarea name="beschreibung" placeholder="Beschreibung"></textarea></div>
|
|
<div class="formrow"><input type="text" name="endpoint" placeholder="Endpoint-URL (optional)"></div>
|
|
<button class="btn" type="submit">Hinzufuegen</button>
|
|
</form></div>"""
|
|
return '<h1 class="page-title">Service-Katalog</h1>' + "".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 '<h1 class="page-title">%s</h1><div class="panel"><p class="sz">%s</p></div>' % (
|
|
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)
|