Kurze Hostnamen in Freigaben aufloesen, Protokolle am Ende oeffnen
Freigaben mit kurzem Hostnamen (\S2025\Share) scheiterten mit "could not resolve address for S2025". Ursache: dieser Server hat keine Suchdomaene (search . in resolv.conf), ein Windows-Client hat eine. Der Name ist also richtig, nur hier nicht auflösbar. Die AD-Domaene ist aus den Einstellungen des Verzeichnisdienstes bekannt und wird jetzt zum Vervollstaendigen benutzt; laesst sich der Name gar nicht aufloesen, sagt die Meldung was zu tun ist statt "Unknown error". Auf POETEST gegen echtes DNS geprueft: \S2025\LDAP_Share -> \S2025.ad.eertmoed.net\LDAP_Share, und dieser Pfad bindet ein. Protokollansichten standen beim Oeffnen oben. Die neueste Zeile steht aber unten, und weil die Aktualisierung nur mitzieht, wenn man ohnehin am Ende ist, zog sie danach nie mit -- es sah aus wie ein kaputter Autoscroll, war aber nur die falsche Anfangsposition. Die Protokollansichten sind jetzt per data-tail markiert und springen beim Laden nach unten. Konfigurationsvorschauen (nginx, Kea) und der Offline-Code sind bewusst nicht markiert: die beginnen oben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -114,6 +115,73 @@ def validate_unc(value: str) -> str:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _resolvable(host: str) -> bool:
|
||||||
|
"""Loest dieser Name auf diesem Server auf?"""
|
||||||
|
try:
|
||||||
|
socket.getaddrinfo(host, None)
|
||||||
|
except (socket.gaierror, UnicodeError, OSError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def search_suffixes(conn: sqlite3.Connection) -> list[str]:
|
||||||
|
"""Domaenen, mit denen ein kurzer Hostname vervollstaendigt werden kann.
|
||||||
|
|
||||||
|
Ein Windows-Client loest "S2025" ueber seine Suchdomaene auf. Dieser Server
|
||||||
|
hat oft keine (``search .`` in resolv.conf), und dann scheitert die
|
||||||
|
Einbindung mit "could not resolve address for S2025" -- obwohl der Name im
|
||||||
|
Netz voellig richtig ist. Die AD-Domaene ist aber bekannt, also wird sie
|
||||||
|
hier zum Vervollstaendigen benutzt.
|
||||||
|
"""
|
||||||
|
kandidaten: list[str] = []
|
||||||
|
einstellungen = core().settings
|
||||||
|
basis = str(einstellungen.get(conn, "ldap_base_dn") or "").strip()
|
||||||
|
if basis:
|
||||||
|
# DC=ad,DC=eertmoed,DC=net -> ad.eertmoed.net
|
||||||
|
teile = [
|
||||||
|
stueck.split("=", 1)[1]
|
||||||
|
for stueck in basis.split(",")
|
||||||
|
if stueck.strip().lower().startswith("dc=")
|
||||||
|
]
|
||||||
|
if teile:
|
||||||
|
kandidaten.append(".".join(teile))
|
||||||
|
rechner = str(einstellungen.get(conn, "ldap_host") or "").strip()
|
||||||
|
if rechner and "." in rechner:
|
||||||
|
kandidaten.append(rechner)
|
||||||
|
# Auch die Elterndomaene: ist ldap_host ein DC-Name, liegt die Domaene
|
||||||
|
# eine Ebene darueber.
|
||||||
|
kandidaten.append(rechner.split(".", 1)[1])
|
||||||
|
# Reihenfolge erhalten, Doppelte entfernen.
|
||||||
|
gesehen: set[str] = set()
|
||||||
|
return [k for k in kandidaten if k and not (k in gesehen or gesehen.add(k))]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
|
||||||
|
"""Ersetzt den Hostnamen einer UNC durch einen auflösbaren.
|
||||||
|
|
||||||
|
Rueckgabe: ``(unc, hinweis)``. Der Hinweis ist leer, wenn nichts geaendert
|
||||||
|
wurde; laesst sich der Name gar nicht aufloesen, bleibt die UNC unveraendert
|
||||||
|
und der Hinweis nennt das Problem.
|
||||||
|
"""
|
||||||
|
teile = unc.lstrip(chr(92)).split(chr(92), 1)
|
||||||
|
if not teile or not teile[0]:
|
||||||
|
return unc, ""
|
||||||
|
host = teile[0]
|
||||||
|
rest = teile[1] if len(teile) > 1 else ""
|
||||||
|
if _resolvable(host):
|
||||||
|
return unc, ""
|
||||||
|
for suffix in suffixes:
|
||||||
|
kandidat = f"{host}.{suffix.strip('.')}"
|
||||||
|
if _resolvable(kandidat):
|
||||||
|
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
||||||
|
return neu, f"Name {host} ueber {kandidat} aufgeloest"
|
||||||
|
return unc, (
|
||||||
|
f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den "
|
||||||
|
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter "
|
||||||
|
"Netzwerk eine Suchdomäne setzen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
return Database.all(
|
return Database.all(
|
||||||
conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
|
conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
|
||||||
@@ -259,12 +327,18 @@ def mount_for_login(
|
|||||||
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
|
|
||||||
|
suffixes = search_suffixes(conn)
|
||||||
for share in shares:
|
for share in shares:
|
||||||
token = secrets.token_urlsafe(16)
|
token = secrets.token_urlsafe(16)
|
||||||
target = root / token
|
target = root / token
|
||||||
result = sysops.mount_cifs(
|
# Kurze Hostnamen vervollstaendigen, bevor mount daran scheitert.
|
||||||
str(share["unc"]), str(target), username, password, domain=domain
|
unc, hinweis = resolve_unc_host(str(share["unc"]), suffixes)
|
||||||
)
|
if unc == str(share["unc"]) and hinweis:
|
||||||
|
messages.append(f"{share['label']}: {hinweis}")
|
||||||
|
continue
|
||||||
|
if hinweis:
|
||||||
|
logger.info("%s: %s", share["label"], hinweis)
|
||||||
|
result = sysops.mount_cifs(unc, str(target), username, password, domain=domain)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
|
messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -102,43 +102,54 @@ def hint_for(output: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def reboot(
|
def split_account(username: str, domain: str = "") -> tuple[str, str]:
|
||||||
host: str,
|
"""Trennt ``DOMAENE\\benutzer`` in Benutzer und Domaene.
|
||||||
|
|
||||||
|
Toleranter Split: mehrfache Backslashes werden zusammengefasst, leere Teile
|
||||||
|
verworfen. Das ist kein Selbstzweck -- der Hinweistext dieser Datei empfahl
|
||||||
|
selbst einmal ``DOMAENE\\\\benutzer`` mit zwei Backslashes, und genau so
|
||||||
|
wurde es dann eingetragen. Ein einfacher ``split()`` schickte daraufhin
|
||||||
|
``\\benutzer`` an Windows, und die Anmeldung scheiterte mit LOGON_FAILURE --
|
||||||
|
bei formal richtigen Zugangsdaten.
|
||||||
|
|
||||||
|
Rueckgabe: ``(benutzer, domaene)``.
|
||||||
|
"""
|
||||||
|
if "\\" in username and not domain:
|
||||||
|
teile = [teil.strip() for teil in username.split("\\") if teil.strip()]
|
||||||
|
if len(teile) >= 2:
|
||||||
|
return teile[-1], teile[0]
|
||||||
|
if teile:
|
||||||
|
return teile[0], domain
|
||||||
|
return username, domain
|
||||||
|
|
||||||
|
|
||||||
|
def _net_with_credentials(
|
||||||
|
args: list[str],
|
||||||
|
*,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
*,
|
domain: str,
|
||||||
domain: str = "",
|
timeout: int,
|
||||||
comment: str = "Neustart durch TESM-Wartung",
|
) -> RemoteResult | tuple[int, str]:
|
||||||
delay: int = 5,
|
"""Ruft ``net`` mit einer 0600-Anmeldedatei auf und raeumt sie sicher weg.
|
||||||
timeout: int = DEFAULT_TIMEOUT,
|
|
||||||
) -> RemoteResult:
|
Gemeinsam fuer Neustart und Pruefung -- die Datei traegt ein
|
||||||
"""Loest einen Neustart auf einem Windows-Geraet aus."""
|
Klartextpasswort, und diese Behandlung will man genau einmal haben.
|
||||||
|
Rueckgabe: ``(exitcode, ausgabe)`` oder ein fertiges ``RemoteResult`` bei
|
||||||
|
einem Fehler noch vor dem Aufruf.
|
||||||
|
"""
|
||||||
binary = shutil.which("net")
|
binary = shutil.which("net")
|
||||||
if binary is None:
|
if binary is None:
|
||||||
return RemoteResult(
|
return RemoteResult(
|
||||||
False,
|
False,
|
||||||
"",
|
"",
|
||||||
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich "
|
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich "
|
||||||
"ein Windows-Geraet von hier nicht neu starten.",
|
"ein Windows-Geraet von hier nicht ansprechen.",
|
||||||
)
|
)
|
||||||
if not username:
|
if not username:
|
||||||
return RemoteResult(False, "", "Fuer das Geraet sind keine Zugangsdaten hinterlegt.")
|
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
||||||
|
|
||||||
# Ein Benutzer der Form DOMAENE\benutzer bringt seine Domaene selbst mit.
|
|
||||||
#
|
|
||||||
# Toleranter Split: mehrfache Backslashes werden zusammengefasst, leere
|
|
||||||
# Teile verworfen. Das ist kein Selbstzweck -- der Hinweistext hier oben
|
|
||||||
# empfahl selbst einmal "DOMAENE\\benutzer" mit zwei Backslashes, und genau
|
|
||||||
# so wurde es dann eingetragen. Ein einfacher split() schickte daraufhin
|
|
||||||
# "\benutzer" an Windows, und die Anmeldung scheiterte mit LOGON_FAILURE --
|
|
||||||
# bei formal richtigen Zugangsdaten.
|
|
||||||
if "\\" in username and not domain:
|
|
||||||
teile = [teil.strip() for teil in username.split("\\") if teil.strip()]
|
|
||||||
if len(teile) >= 2:
|
|
||||||
domain, username = teile[0], teile[-1]
|
|
||||||
elif teile:
|
|
||||||
username = teile[0]
|
|
||||||
|
|
||||||
|
username, domain = split_account(username, domain)
|
||||||
handle, path = tempfile.mkstemp(prefix="tesm-winauth-")
|
handle, path = tempfile.mkstemp(prefix="tesm-winauth-")
|
||||||
try:
|
try:
|
||||||
# mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem
|
# mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem
|
||||||
@@ -153,21 +164,7 @@ def reboot(
|
|||||||
if domain:
|
if domain:
|
||||||
stream.write(f"domain={domain}\n")
|
stream.write(f"domain={domain}\n")
|
||||||
|
|
||||||
command = [
|
command = [binary, *args, "--authentication-file", path]
|
||||||
binary,
|
|
||||||
"rpc",
|
|
||||||
"shutdown",
|
|
||||||
"-r", # neu starten statt herunterfahren
|
|
||||||
"-f", # laufende Anwendungen nicht blockieren lassen
|
|
||||||
"-t",
|
|
||||||
str(max(0, int(delay))),
|
|
||||||
"-C",
|
|
||||||
comment,
|
|
||||||
"-I",
|
|
||||||
host,
|
|
||||||
"--authentication-file",
|
|
||||||
path,
|
|
||||||
]
|
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
||||||
command, capture_output=True, text=True, timeout=timeout, check=False
|
command, capture_output=True, text=True, timeout=timeout, check=False
|
||||||
@@ -176,11 +173,7 @@ def reboot(
|
|||||||
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
|
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return RemoteResult(False, "", str(exc))
|
return RemoteResult(False, "", str(exc))
|
||||||
|
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||||
output = (completed.stdout + completed.stderr).strip()
|
|
||||||
if completed.returncode == 0:
|
|
||||||
return RemoteResult(True, output or "Neustart ausgeloest.")
|
|
||||||
return RemoteResult(False, output, hint_for(output))
|
|
||||||
finally:
|
finally:
|
||||||
# Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort.
|
# Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort.
|
||||||
try:
|
try:
|
||||||
@@ -196,3 +189,85 @@ def reboot(
|
|||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
except OSError: # pragma: no cover - defensiv
|
except OSError: # pragma: no cover - defensiv
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def check(
|
||||||
|
host: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
domain: str = "",
|
||||||
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
|
) -> RemoteResult:
|
||||||
|
"""Prueft Zugangsdaten gegen ein Windows-Geraet -- **ohne** etwas zu aendern.
|
||||||
|
|
||||||
|
``net rpc info`` ist rein lesend. Der Sinn: ob Zugangsdaten stimmen, soll
|
||||||
|
man erfahren, ohne einen Neustart auszuloesen und hinterher im Protokoll zu
|
||||||
|
raten. Unterschieden wird ausdruecklich zwischen "Passwort falsch" und
|
||||||
|
"Anmeldung in Ordnung, aber keine Administratorrechte" -- das sind zwei
|
||||||
|
voellig verschiedene Aufgaben fuer den, der es beheben muss.
|
||||||
|
|
||||||
|
Ein Fehlversuch zaehlt fuer die Sperrrichtlinie der Domaene mit. Deshalb
|
||||||
|
genau ein Aufruf je Klick und kein automatisches Wiederholen.
|
||||||
|
"""
|
||||||
|
ergebnis = _net_with_credentials(
|
||||||
|
["rpc", "info", "-I", host],
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
domain=domain,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if isinstance(ergebnis, RemoteResult):
|
||||||
|
return ergebnis
|
||||||
|
code, output = ergebnis
|
||||||
|
|
||||||
|
if code == 0:
|
||||||
|
return RemoteResult(
|
||||||
|
True, output, "Anmeldung erfolgreich, Administratorrechte vorhanden."
|
||||||
|
)
|
||||||
|
if "ACCESS_DENIED" in output:
|
||||||
|
# Wichtig: die Anmeldung *hat* funktioniert. Nur die Rechte fehlen.
|
||||||
|
return RemoteResult(
|
||||||
|
False,
|
||||||
|
output,
|
||||||
|
"Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine "
|
||||||
|
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.",
|
||||||
|
)
|
||||||
|
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.")
|
||||||
|
|
||||||
|
|
||||||
|
def reboot(
|
||||||
|
host: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
domain: str = "",
|
||||||
|
comment: str = "Neustart durch TESM-Wartung",
|
||||||
|
delay: int = 5,
|
||||||
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
|
) -> RemoteResult:
|
||||||
|
"""Loest einen Neustart auf einem Windows-Geraet aus."""
|
||||||
|
ergebnis = _net_with_credentials(
|
||||||
|
[
|
||||||
|
"rpc",
|
||||||
|
"shutdown",
|
||||||
|
"-r", # neu starten statt herunterfahren
|
||||||
|
"-f", # laufende Anwendungen nicht blockieren lassen
|
||||||
|
"-t",
|
||||||
|
str(max(0, int(delay))),
|
||||||
|
"-C",
|
||||||
|
comment,
|
||||||
|
"-I",
|
||||||
|
host,
|
||||||
|
],
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
domain=domain,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if isinstance(ergebnis, RemoteResult):
|
||||||
|
return ergebnis
|
||||||
|
code, output = ergebnis
|
||||||
|
if code == 0:
|
||||||
|
return RemoteResult(True, output or "Neustart ausgeloest.")
|
||||||
|
return RemoteResult(False, output, hint_for(output))
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<pre class="log-view">{{ job.output or 'Noch keine Ausgabe.' }}</pre>
|
<pre class="log-view" data-tail>{{ job.output or 'Noch keine Ausgabe.' }}</pre>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekun
|
|||||||
{{ icon('copy', size=14) }} Mitschrift kopieren
|
{{ icon('copy', size=14) }} Mitschrift kopieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre class="log-view" id="console-output" style="max-height:56dvh"></pre>
|
<pre class="log-view" data-tail id="console-output" style="max-height:56dvh"></pre>
|
||||||
<div class="card__footer" style="justify-content:stretch;gap:var(--sp-2)">
|
<div class="card__footer" style="justify-content:stretch;gap:var(--sp-2)">
|
||||||
<select class="select" id="console-preset" style="max-width:280px">
|
<select class="select" id="console-preset" style="max-width:280px">
|
||||||
<option value="">Kommando aus der Liste ...</option>
|
<option value="">Kommando aus der Liste ...</option>
|
||||||
|
|||||||
@@ -563,7 +563,8 @@ function initAutoRefresh() {
|
|||||||
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
||||||
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
||||||
// Aktualisierung an den Anfang zurueck.
|
// Aktualisierung an den Anfang zurueck.
|
||||||
const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el;
|
const scrollerOf = (el) =>
|
||||||
|
el.querySelector("[data-tail], .log-view, [data-scroll]") || el;
|
||||||
const before = scrollerOf(region);
|
const before = scrollerOf(region);
|
||||||
const position = before.scrollTop;
|
const position = before.scrollTop;
|
||||||
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
||||||
@@ -814,6 +815,25 @@ function initTileFilters() {
|
|||||||
|
|
||||||
/* -- Start ---------------------------------------------------------------- */
|
/* -- Start ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokollansichten stehen am Ende, nicht am Anfang.
|
||||||
|
*
|
||||||
|
* Bei einem Protokoll ist die neueste Zeile die interessante, und die steht
|
||||||
|
* unten. Ohne diesen Anfangssprung blieb die Ansicht oben stehen -- und weil
|
||||||
|
* die Aktualisierung nur mitzieht, wenn man ohnehin schon am Ende ist, zog
|
||||||
|
* sie danach nie mit. Es sah aus, als waere der Autoscroll kaputt; in
|
||||||
|
* Wirklichkeit stand man nur nie an der Stelle, an der er greift.
|
||||||
|
*
|
||||||
|
* Ausdruecklich per ``data-tail`` markiert: eine Konfigurationsvorschau
|
||||||
|
* (nginx, Kea) und der Offline-Code sind auch <pre class="log-view">, sollen
|
||||||
|
* aber oben beginnen.
|
||||||
|
*/
|
||||||
|
function initLogTails(root = document) {
|
||||||
|
root.querySelectorAll("[data-tail]").forEach((view) => {
|
||||||
|
view.scrollTop = view.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
initTheme();
|
initTheme();
|
||||||
initSidebar();
|
initSidebar();
|
||||||
@@ -830,6 +850,7 @@ function boot() {
|
|||||||
initPalette();
|
initPalette();
|
||||||
initSubmitGuards();
|
initSubmitGuards();
|
||||||
initTileFilters();
|
initTileFilters();
|
||||||
|
initLogTails();
|
||||||
unsavedGuard.init();
|
unsavedGuard.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% if lines %}
|
{% if lines %}
|
||||||
<pre class="log-view">{% for line in lines %}{{ line }}
|
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
|
||||||
{% endfor %}</pre>
|
{% endfor %}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user