Watch EPIRent version and archive its download packages

- Poll the public download page for "Aktuelle Version <nr> vom <date>",
  report changes through the normal change feed and notification mail,
  and show the current version as a card on the overview. Runs on its own
  interval (default 30 min); the first run only records a baseline.
- Archive epirentServer.zip / epirentClient.zip per version number so we
  keep a download cache and history: streamed to disk with SHA-256 and
  size, fetched in a background thread so ticket polling is unaffected,
  with a free-space check before downloading (~1.1 GB per version).
- Retention is configurable via EPIRENT_DOWNLOAD_KEEP, where 0 means
  unlimited; a successful archive is reported as a change and by mail.
- New Downloads page listing the archived versions with checksums and
  links to fetch the cached files.
- Notification lines now omit the arrow when there is no previous value
  and drop the "#" for entries without a ticket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 15:12:48 +02:00
co-authored by Claude Opus 4.8
parent 528d66b2b0
commit bdcb17285c
13 changed files with 575 additions and 10 deletions
+15
View File
@@ -21,6 +21,21 @@ ZAMMAD_PASSWORD=
# Intervall des Hintergrund-Abrufs in Sekunden
POLL_INTERVAL_SECONDS=180
# --- EPIRent-Versionsüberwachung ----------------------------
# Liest "Aktuelle Version / NNNNN vom TT.MM.JJJJ" von der Download-Seite und
# meldet Änderungen wie jede andere Änderung (Feed + E-Mail).
EPIRENT_CHECK_ENABLED=true
EPIRENT_URL=https://www.epi.rent/downloads
EPIRENT_CHECK_INTERVAL_SECONDS=1800
# Download-Archiv: sichert die Pakete je Versionsnummer (Verlauf/Cache).
# ACHTUNG: ca. 1,1 GB pro Versionsstand (Server ~923 MB + Client ~218 MB).
EPIRENT_DOWNLOAD_ENABLED=true
EPIRENT_DOWNLOAD_URLS=https://download.epi.rent/latest/epirentServer.zip,https://download.epi.rent/latest/epirentClient.zip
# Wie viele Versionsstände aufbewahrt werden (3 => ca. 3,4 GB).
# 0 = unbegrenzt (nichts wird automatisch gelöscht Plattenplatz im Auge behalten!)
EPIRENT_DOWNLOAD_KEEP=3
# --- Web ----------------------------------------------------
# Pfad-Präfix hinter Apache (srhnweb/episupport)
EPISUPPORT_URL_PREFIX=/episupport
+19
View File
@@ -28,6 +28,25 @@ ZAMMAD_PASSWORD = os.getenv("ZAMMAD_PASSWORD", "")
# --- Abruf ---
POLL_INTERVAL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", "180"))
# --- EPIRent-Versionsüberwachung (Download-Seite) ---
EPIRENT_CHECK_ENABLED = _b(os.getenv("EPIRENT_CHECK_ENABLED"), True)
EPIRENT_URL = os.getenv("EPIRENT_URL", "https://www.epi.rent/downloads")
EPIRENT_CHECK_INTERVAL_SECONDS = int(
os.getenv("EPIRENT_CHECK_INTERVAL_SECONDS", "1800"))
# --- Download-Archiv (pro Version zwischengespeichert) ---
EPIRENT_DOWNLOAD_ENABLED = _b(os.getenv("EPIRENT_DOWNLOAD_ENABLED"), True)
EPIRENT_DOWNLOAD_URLS = [
u.strip() for u in os.getenv(
"EPIRENT_DOWNLOAD_URLS",
"https://download.epi.rent/latest/epirentServer.zip,"
"https://download.epi.rent/latest/epirentClient.zip"
).split(",") if u.strip()
]
# Wie viele Versionsstände aufbewahrt werden (je ~1,1 GB!).
# 0 = unbegrenzt aufbewahren (es wird nichts automatisch gelöscht).
EPIRENT_DOWNLOAD_KEEP = int(os.getenv("EPIRENT_DOWNLOAD_KEEP", "3"))
# --- Web ---
URL_PREFIX = os.getenv("EPISUPPORT_URL_PREFIX", "/episupport").rstrip("/")
PORT = int(os.getenv("EPISUPPORT_PORT", "8071"))
+22
View File
@@ -70,6 +70,28 @@ CREATE TABLE IF NOT EXISTS articles (
CREATE INDEX IF NOT EXISTS idx_articles_ticket ON articles(ticket_id, created_at);
CREATE TABLE IF NOT EXISTS versions (
source TEXT PRIMARY KEY,
version TEXT,
released_at TEXT,
checked_at TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL,
filename TEXT NOT NULL,
url TEXT,
size INTEGER,
sha256 TEXT,
remote_modified TEXT,
downloaded_at TEXT,
UNIQUE(version, filename)
);
CREATE INDEX IF NOT EXISTS idx_downloads_version ON downloads(version);
CREATE TABLE IF NOT EXISTS sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT,
+256
View File
@@ -0,0 +1,256 @@
"""Download-Archiv: sichert die EPIRent-Pakete je Versionsnummer.
Die Dateien sind groß (Server ~923 MB, Client ~218 MB, zusammen ~1,1 GB pro
Version). Deshalb:
* Download läuft im Hintergrund-Thread (Ticketabruf blockiert nicht),
* gestreamt auf Platte (kein Speicherverbrauch),
* Platzprüfung vorab,
* Aufbewahrung begrenzt (EPIRENT_DOWNLOAD_KEEP, Standard 3 Stände).
"""
import hashlib
import logging
import shutil
import threading
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import requests
from . import config, db, notify
log = logging.getLogger("episupport.downloads")
DOWNLOAD_DIR = config.DATA_DIR / "downloads"
_lock = threading.Lock()
_running = False
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def filename_for(url: str) -> str:
name = Path(urlparse(url).path).name
return name or "download.bin"
def version_dir(version: str) -> Path:
# Versionsnummern sind rein numerisch zur Sicherheit trotzdem säubern
safe = "".join(ch for ch in str(version) if ch.isalnum() or ch in "._-")
return DOWNLOAD_DIR / (safe or "unbekannt")
def archived(version: str) -> list[dict]:
with db.get_conn() as conn:
rows = conn.execute(
"SELECT * FROM downloads WHERE version=? ORDER BY filename",
(str(version),)).fetchall()
return [dict(r) for r in rows]
def is_complete(version: str) -> bool:
"""Sind alle konfigurierten Dateien für diese Version vorhanden?"""
want = {filename_for(u) for u in config.EPIRENT_DOWNLOAD_URLS}
have = {r["filename"] for r in archived(version)
if (version_dir(version) / r["filename"]).exists()}
return bool(want) and want.issubset(have)
def history() -> list[dict]:
"""Alle archivierten Versionen mit Dateien und Gesamtgröße."""
with db.get_conn() as conn:
rows = conn.execute(
"SELECT * FROM downloads ORDER BY downloaded_at DESC, filename"
).fetchall()
by_version: dict[str, dict] = {}
for r in rows:
v = by_version.setdefault(r["version"], {
"version": r["version"], "files": [], "total": 0,
"downloaded_at": r["downloaded_at"]})
v["files"].append(dict(r))
v["total"] += r["size"] or 0
return list(by_version.values())
def human_size(n) -> str:
if not n:
return ""
n = float(n)
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.0f} {unit}" if unit in ("B", "KB") else f"{n:.1f} {unit}".replace(".", ",")
n /= 1024
return f"{n:.1f} GB"
# ------------------------------------------------------------------ Download
def _remote_info(url: str) -> tuple[int, str | None]:
"""(Größe, Last-Modified) per HEAD ermitteln."""
r = requests.head(url, timeout=30, allow_redirects=True,
headers={"User-Agent": "episupport-monitor/1.0"})
r.raise_for_status()
return int(r.headers.get("Content-Length") or 0), r.headers.get("Last-Modified")
def _download(url: str, dest: Path) -> tuple[int, str]:
"""Streamend herunterladen; gibt (Größe, sha256) zurück."""
tmp = dest.with_suffix(dest.suffix + ".part")
h = hashlib.sha256()
size = 0
with requests.get(url, stream=True, timeout=(30, 300),
headers={"User-Agent": "episupport-monitor/1.0"}) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 1024):
if not chunk:
continue
f.write(chunk)
h.update(chunk)
size += len(chunk)
tmp.replace(dest)
return size, h.hexdigest()
def _enough_space(needed: int) -> bool:
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
free = shutil.disk_usage(DOWNLOAD_DIR).free
if free < needed * 1.2:
log.error("Zu wenig Speicherplatz: benötigt ~%s, frei %s.",
human_size(needed), human_size(free))
return False
return True
def fetch_version(version: str) -> list[dict]:
"""Fehlende Dateien dieser Version holen. Gibt die neu geholten zurück."""
urls = config.EPIRENT_DOWNLOAD_URLS
if not urls:
return []
target = version_dir(version)
target.mkdir(parents=True, exist_ok=True)
todo = []
for url in urls:
name = filename_for(url)
if (target / name).exists() and any(
r["filename"] == name for r in archived(version)):
continue
todo.append((url, name))
if not todo:
return []
# Platzbedarf vorab prüfen
needed = 0
infos = {}
for url, name in todo:
try:
size, modified = _remote_info(url)
except requests.RequestException as e:
log.warning("HEAD fehlgeschlagen für %s: %s", url, e)
size, modified = 0, None
infos[name] = (size, modified)
needed += size
if needed and not _enough_space(needed):
return []
got = []
for url, name in todo:
dest = target / name
try:
log.info("Lade %s (%s) für Version %s", name,
human_size(infos[name][0]), version)
size, digest = _download(url, dest)
with db.get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO downloads (version, filename, url, "
"size, sha256, remote_modified, downloaded_at) "
"VALUES (?,?,?,?,?,?,?)",
(str(version), name, url, size, digest,
infos[name][1], _now()))
got.append({"filename": name, "size": size, "sha256": digest})
log.info("Fertig: %s (%s)", name, human_size(size))
except (requests.RequestException, OSError) as e:
log.error("Download %s fehlgeschlagen: %s", url, e)
try:
(target / (name + ".part")).unlink(missing_ok=True)
except OSError:
pass
return got
def cleanup(keep: int | None = None) -> list[str]:
"""Alte Versionsstände löschen, nur die neuesten `keep` behalten.
`keep = 0` (oder negativ) bedeutet: unbegrenzt aufbewahren es wird
nichts gelöscht.
"""
keep = config.EPIRENT_DOWNLOAD_KEEP if keep is None else keep
if keep <= 0:
return []
versions = [v["version"] for v in history()]
removed = []
for v in versions[keep:]:
d = version_dir(v)
shutil.rmtree(d, ignore_errors=True)
with db.get_conn() as conn:
conn.execute("DELETE FROM downloads WHERE version=?", (v,))
removed.append(v)
log.info("Alten Download-Stand %s entfernt.", v)
return removed
# --------------------------------------------------------------- Hintergrund
def archive_async(version: str) -> None:
"""Archivierung im Hintergrund starten (max. eine gleichzeitig)."""
global _running
if not config.EPIRENT_DOWNLOAD_ENABLED or not version:
return
with _lock:
if _running:
log.info("Archivierung läuft bereits überspringe.")
return
_running = True
def _work():
global _running
try:
got = fetch_version(version)
removed = cleanup()
if got:
_report(version, got, removed)
except Exception: # noqa: BLE001
log.exception("Archivierung fehlgeschlagen")
finally:
with _lock:
_running = False
threading.Thread(target=_work, name="epirent-archive", daemon=True).start()
def _report(version: str, got: list[dict], removed: list[str]) -> None:
"""Erfolgreiche Archivierung als Änderung erfassen und mailen."""
parts = [f"{g['filename']} ({human_size(g['size'])})" for g in got]
detail = ", ".join(parts)
if removed:
detail += f" · alte Stände entfernt: {', '.join(removed)}"
change = {
"ticket_number": None,
"ticket_title": "EPIRent Download-Archiv",
"field": "epirent_download",
"label": "Downloads gesichert",
"old_value": None,
"new_value": f"Version {version}",
"detail": detail,
}
with db.get_conn() as conn:
conn.execute(
"INSERT INTO changes (ticket_id, ticket_number, ticket_title, field, "
"label, old_value, new_value, detail, detected_at) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(0, None, change["ticket_title"], change["field"], change["label"],
None, change["new_value"], detail, _now()))
try:
notify.send_change_mail([change])
except Exception: # noqa: BLE001
log.exception("Mail zur Archivierung fehlgeschlagen")
+146
View File
@@ -0,0 +1,146 @@
"""Überwachung der EPIRent-Version auf der öffentlichen Download-Seite.
Die Seite (Squarespace) enthält im Klartext einen Block der Form:
<p>Aktuelle Version</p><p>14505 vom 30.06.2026</p>
Wir lesen Version + Datum aus, vergleichen mit dem zuletzt gesehenen Stand und
melden eine Änderung wie jede andere (Feed + E-Mail).
"""
import logging
import re
from datetime import datetime, timezone
import requests
from . import config, db
log = logging.getLogger("episupport.epirent")
SOURCE = "epirent"
# "14505 vom 30.06.2026"
_PATTERN = re.compile(r"(\d{4,6})\s*vom\s*(\d{1,2}\.\d{1,2}\.\d{4})", re.I)
_TAGS = re.compile(r"<[^>]+>")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def parse_version(html: str) -> tuple[str, str] | None:
"""(Version, Datum) aus dem Seiten-HTML lesen."""
if not html:
return None
# Bevorzugt direkt nach der Überschrift "Aktuelle Version" suchen
idx = html.find("Aktuelle Version")
if idx != -1:
segment = " ".join(_TAGS.sub(" ", html[idx:idx + 600]).split())
m = _PATTERN.search(segment)
if m:
return m.group(1), m.group(2)
# Rückfall: im gesamten Dokument suchen
m = _PATTERN.search(_TAGS.sub(" ", html))
return (m.group(1), m.group(2)) if m else None
def fetch_version(timeout: int = 30) -> tuple[str, str] | None:
r = requests.get(
config.EPIRENT_URL,
timeout=timeout,
headers={"User-Agent": "episupport-monitor/1.0"},
)
r.raise_for_status()
return parse_version(r.text)
def _due(conn) -> bool:
"""Ist der nächste Abruf fällig (Intervall abgelaufen)?"""
row = conn.execute(
"SELECT checked_at FROM versions WHERE source=?", (SOURCE,)
).fetchone()
if not row or not row["checked_at"]:
return True
try:
last = datetime.fromisoformat(row["checked_at"])
except ValueError:
return True
age = (datetime.now(timezone.utc) - last).total_seconds()
return age >= config.EPIRENT_CHECK_INTERVAL_SECONDS
def check(force: bool = False) -> dict | None:
"""Version prüfen. Gibt bei Änderung ein Change-Dict zurück, sonst None.
Muss AUSSERHALB einer offenen Poller-Transaktion laufen (eigene Verbindung).
"""
if not config.EPIRENT_CHECK_ENABLED:
return None
with db.get_conn() as conn:
if not force and not _due(conn):
return None
previous = conn.execute(
"SELECT * FROM versions WHERE source=?", (SOURCE,)
).fetchone()
try:
found = fetch_version()
except requests.RequestException as e:
log.warning("EPIRent-Seite nicht abrufbar: %s", e)
return None
if not found:
log.warning("Version auf %s nicht gefunden (Seitenaufbau geändert?).",
config.EPIRENT_URL)
return None
version, released = found
now = _now()
# Erster Lauf: nur merken, nicht melden
if previous is None:
with db.get_conn() as conn:
conn.execute(
"INSERT INTO versions (source, version, released_at, checked_at, "
"updated_at) VALUES (?,?,?,?,?)",
(SOURCE, version, released, now, now))
log.info("EPIRent-Version erstmals erfasst: %s vom %s", version, released)
return None
unchanged = (previous["version"] == version
and previous["released_at"] == released)
if unchanged:
with db.get_conn() as conn:
conn.execute("UPDATE versions SET checked_at=? WHERE source=?",
(now, SOURCE))
return None
# --- Änderung erkannt ---
old_txt = f"{previous['version']} vom {previous['released_at']}"
new_txt = f"{version} vom {released}"
log.info("Neue EPIRent-Version: %s -> %s", old_txt, new_txt)
with db.get_conn() as conn:
conn.execute(
"UPDATE versions SET version=?, released_at=?, checked_at=?, "
"updated_at=? WHERE source=?",
(version, released, now, now, SOURCE))
conn.execute(
"INSERT INTO changes (ticket_id, ticket_number, ticket_title, field, "
"label, old_value, new_value, detail, detected_at) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(0, None, "EPIRent Download-Seite", "epirent_version",
"Neue EPIRent-Version", old_txt, new_txt, config.EPIRENT_URL, now))
return {
"ticket_number": None,
"ticket_title": "EPIRent Download-Seite",
"field": "epirent_version",
"label": "Neue EPIRent-Version",
"old_value": old_txt,
"new_value": new_txt,
"detail": config.EPIRENT_URL,
}
def current() -> dict | None:
with db.get_conn() as conn:
row = conn.execute(
"SELECT * FROM versions WHERE source=?", (SOURCE,)).fetchone()
return dict(row) if row else None
+8 -3
View File
@@ -22,18 +22,23 @@ def send_change_mail(changes: list[dict]) -> None:
lines = []
for c in changes:
line = f"#{c['ticket_number']} {c['ticket_title']}\n {c['label']}: {c.get('old_value') or ''} -> {c.get('new_value') or ''}"
# Einträge ohne Ticketbezug (z. B. EPIRent-Version) ohne "#" ausgeben
head = f"#{c['ticket_number']} " if c.get("ticket_number") else ""
new = c.get("new_value") or ""
# ohne Vorwert (z. B. neues Ticket, Download-Archiv) keinen Pfeil setzen
value = f"{c['old_value']} -> {new}" if c.get("old_value") else new
line = f"{head}{c['ticket_title']}\n {c['label']}: {value}"
if c.get("detail"):
line += f"\n {c['detail']}"
lines.append(line)
body = (
f"Es gibt {len(changes)} neue Änderung(en) an euren EPI-Support-Tickets:\n\n"
f"Es gibt {len(changes)} neue Änderung(en):\n\n"
+ "\n\n".join(lines)
+ f"\n\nDashboard: {config.ZAMMAD_URL}\n(Quelle: EPI-Support-Monitor)"
)
msg = EmailMessage()
msg["Subject"] = f"[EPI-Support] {len(changes)} Ticket-Änderung(en)"
msg["Subject"] = f"[EPI-Support] {len(changes)} Änderung(en)"
msg["From"] = config.SMTP_FROM
msg["To"] = ", ".join(config.NOTIFY_TO)
msg.set_content(body)
+19 -1
View File
@@ -5,7 +5,7 @@ import logging
import time
from datetime import datetime, timezone
from . import config, conversation, db, notify
from . import config, conversation, db, downloads, epirent, notify
from .zammad import ZammadClient, ZammadError
log = logging.getLogger("episupport.poller")
@@ -138,6 +138,18 @@ def _detect_changes(conn, client, new_t: dict, old_row) -> list[dict]:
return found
def _archive_downloads() -> None:
"""Download-Pakete der aktuellen EPIRent-Version sichern (im Hintergrund)."""
if not config.EPIRENT_DOWNLOAD_ENABLED:
return
cur = epirent.current()
if not cur or not cur.get("version"):
return
if downloads.is_complete(cur["version"]):
return
downloads.archive_async(cur["version"])
def sync_ticket(client: ZammadClient, ticket_id: int) -> None:
"""Ein einzelnes Ticket (+ Artikel) sofort in die DB übernehmen.
@@ -205,6 +217,12 @@ def poll_once(client: ZammadClient) -> int:
all_changes.extend(ch)
# Backfill NACH der Haupttransaktion (eigene, kurze Verbindungen)
_backfill_articles(client)
# EPIRent-Version auf der Download-Seite prüfen (eigenes Intervall)
version_change = epirent.check()
if version_change:
all_changes.append(version_change)
# Pakete dieser Version im Hintergrund archivieren, falls noch nicht da
_archive_downloads()
except ZammadError as e:
status = "error"
error = str(e)
+25 -3
View File
@@ -9,9 +9,10 @@ from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from flask import (Flask, Response, abort, flash, redirect, render_template,
request, url_for)
request, send_file, url_for)
from . import analytics, charts, config, conversation, db, export, poller, uploads
from . import (analytics, charts, config, conversation, db, downloads, epirent,
export, poller, uploads)
from .zammad import ZammadClient, ZammadError
log = logging.getLogger("episupport.web")
@@ -201,7 +202,8 @@ def create_app() -> Flask:
stats=stats, recent=recent, last_run=last_run,
active_state=state, sort=sort, dir=direction,
unseen_map=unseen_map, show_archived=show_archived,
archived_count=archived_count)
archived_count=archived_count,
epirent_version=epirent.current())
@app.route("/ticket/<int:ticket_id>")
def ticket_detail(ticket_id):
@@ -447,6 +449,26 @@ def create_app() -> Flask:
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@app.route("/downloads")
def downloads_view():
return render_template("downloads.html",
versions=downloads.history(),
human=downloads.human_size,
keep=config.EPIRENT_DOWNLOAD_KEEP,
current=epirent.current())
@app.route("/downloads/<version>/<path:filename>")
def download_file(version, filename):
# nur bekannte, in der DB verzeichnete Dateien ausliefern
entry = next((r for r in downloads.archived(version)
if r["filename"] == filename), None)
if not entry:
abort(404)
path = downloads.version_dir(version) / filename
if not path.exists():
abort(404)
return send_file(path, as_attachment=True, download_name=filename)
@app.route("/health")
def health():
with db.get_conn() as conn:
+13 -1
View File
@@ -33,7 +33,7 @@ footer { max-width: 1100px; margin: 2rem auto; padding: 1rem; color: var(--muted
background: #e8f5ee; border: 1px solid #b7e0c9; border-radius: 6px;
}
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.4rem; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 1rem; margin-bottom: 1.4rem; }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 1rem 1.2rem; box-shadow: var(--shadow);
@@ -106,6 +106,18 @@ footer { max-width: 1100px; margin: 2rem auto; padding: 1rem; color: var(--muted
.tag-article { background: #e8f5ee; color: #0b6e4f; }
.tag-new_ticket { background: #f3e8fd; color: #6b27a8; }
.tag-priority { background: #fff3d6; color: #8a6300; }
.tag-epirent_version { background: #e6f7f4; color: #0a6b62; }
.tag-epirent_download { background: #eef0fd; color: #3b3fa8; }
/* --- Download-Archiv --- */
.dl-version { padding: .8rem 1.1rem; border-bottom: 1px solid var(--line); }
.dl-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.dl-ver { font-weight: 700; }
.dl-meta { color: var(--muted); font-size: .82rem; }
.dl-files { list-style: none; margin: .5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .3rem; }
.dl-files li { display: flex; align-items: center; gap: .7rem; font-size: .88rem; flex-wrap: wrap; }
.dl-size { color: var(--muted); font-size: .82rem; }
.dl-hash { color: #8a95a0; font-size: .75rem; }
.chips { display: flex; flex-wrap: wrap; gap: .4rem; padding: 1rem 1.1rem; }
.chip { background: #eef2f5; padding: .25rem .6rem; border-radius: 999px; font-size: .82rem; }
+3 -1
View File
@@ -2,9 +2,11 @@
<li class="{{ 'unseen' if not c.seen else '' }}"
{% if c.detail %}title="{{ c.detail }}"{% endif %}>
<span class="tag tag-{{ c.field }}">{{ c.label }}</span>
{% if c.ticket_id %}
<a class="cnum" href="{{ url_for('ticket_detail', ticket_id=c.ticket_id) }}">#{{ c.ticket_number }}</a>
{% endif %}
<span class="ctitle">{{ c.ticket_title }}</span>
<span class="cchg">{{ c.old_value or '' }} → <b>{{ c.new_value or '' }}</b></span>
<span class="cchg">{% if c.old_value %}{{ c.old_value }} → {% endif %}<b>{{ c.new_value or '' }}</b></span>
<time>{{ c.detected_at|dt }}</time>
{% if not c.seen %}
<form method="post" action="{{ url_for('mark_one_read', change_id=c.id) }}" class="mark-one">
+1
View File
@@ -13,6 +13,7 @@
<a href="{{ url_for('index') }}">Übersicht</a>
<a href="{{ url_for('analytics_view') }}">Auswertung</a>
<a href="{{ url_for('changes') }}">Änderungen</a>
<a href="{{ url_for('downloads_view') }}">Downloads</a>
<a href="{{ url_for('new_ticket_form') }}">+ Neues Ticket</a>
<a href="{{ zammad_url }}" target="_blank" rel="noopener">Zammad&nbsp;</a>
</nav>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Downloads · EPI-Support{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h2>EPIRent Download-Archiv</h2>
{% if current %}
<span class="hint-inline">aktuelle Version: <b>{{ current.version }}</b> vom {{ current.released_at }}</span>
{% endif %}
</div>
{% if versions %}
{% for v in versions %}
<div class="dl-version">
<div class="dl-head">
<span class="dl-ver">Version {{ v.version }}</span>
<span class="dl-meta">{{ human(v.total) }} · gesichert am {{ v.downloaded_at|dt }}</span>
</div>
<ul class="dl-files">
{% for f in v.files %}
<li>
<a href="{{ url_for('download_file', version=v.version, filename=f.filename) }}">⬇ {{ f.filename }}</a>
<span class="dl-size">{{ human(f.size) }}</span>
{% if f.sha256 %}<code class="dl-hash" title="SHA-256">{{ f.sha256[:16] }}…</code>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% if keep and keep > 0 %}
<p class="hint">Es werden die letzten <b>{{ keep }}</b> Versionsstände aufbewahrt; ältere werden automatisch gelöscht.</p>
{% else %}
<p class="hint">Aufbewahrung: <b>unbegrenzt</b> es wird nichts automatisch gelöscht (ca. 1,1 GB je Stand).</p>
{% endif %}
{% else %}
<p class="muted">Noch keine Pakete archiviert. Der Poller lädt sie im Hintergrund,
sobald eine Version bekannt ist (ca. 1,1 GB pro Stand das dauert einen Moment).</p>
{% endif %}
</section>
{% endblock %}
+6
View File
@@ -9,6 +9,12 @@
<div class="card {{ 'alert' if stats.unseen else '' }}">
<span class="num">{{ stats.unseen }}</span><span class="lbl">ungelesene Änderungen</span>
</div>
{% if epirent_version %}
<div class="card">
<span class="num">{{ epirent_version.version }}</span>
<span class="lbl">EPIRent-Version<br>vom {{ epirent_version.released_at }}</span>
</div>
{% endif %}
<div class="card status">
{% if last_run %}
<span class="num {{ 'ok' if last_run.status == 'ok' else 'err' }}">