Version 2.8.24: Massenbefehl, Mitlesen, F5
This commit is contained in:
1 parent
914609b758
commit
9d736bb91f
12 files changed
+452
-19
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.8.23"
|
version = "2.8.24"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from __future__ import annotations
|
|||||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||||
from tesm_core.security.sessions import fragment
|
from tesm_core.security.sessions import fragment
|
||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
from tesm_core.auth.models import current_user, permission_required
|
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||||
from tesm_core.auth.service import client_ip
|
from tesm_core.auth.service import client_ip
|
||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
@@ -77,7 +77,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
|
|||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required(*SERVICING_PAGE, mode="any")
|
@permission_required(*SERVICING_PAGE, mode="any")
|
||||||
def overview():
|
def overview(): # noqa: C901 - eine Seite, drei Aufgaben
|
||||||
conn = db()
|
conn = db()
|
||||||
return render_template(
|
return render_template(
|
||||||
"tesm/maintenance.html",
|
"tesm/maintenance.html",
|
||||||
@@ -89,6 +89,7 @@ def overview():
|
|||||||
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
|
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
|
||||||
windows_tool_missing=_windows_tool_missing(conn),
|
windows_tool_missing=_windows_tool_missing(conn),
|
||||||
windows_package=windowsops.REQUIRED_PACKAGE,
|
windows_package=windowsops.REQUIRED_PACKAGE,
|
||||||
|
befehlsvorlagen=service.BEFEHLSVORLAGEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,6 +151,13 @@ def status():
|
|||||||
{
|
{
|
||||||
"running": running,
|
"running": running,
|
||||||
"poll_ms": 2000 if running else 10000,
|
"poll_ms": 2000 if running else 10000,
|
||||||
|
# Der Zustand der Geraete gehoert dazu: die Seite steht sonst mit
|
||||||
|
# dem Stand ihres Aufbaus da, und wer nach einem Neustart auf die
|
||||||
|
# Rueckkehr wartet, sieht "unbekannt", bis er von Hand neu laedt.
|
||||||
|
"devices": [
|
||||||
|
{"id": int(row["id"]), "state": str(row["state"] or "unknown")}
|
||||||
|
for row in _eligible_devices(conn)
|
||||||
|
],
|
||||||
"jobs": [
|
"jobs": [
|
||||||
{
|
{
|
||||||
"id": job["id"],
|
"id": job["id"],
|
||||||
@@ -159,6 +167,16 @@ def status():
|
|||||||
"progress": job["progress"],
|
"progress": job["progress"],
|
||||||
"message": job["message"],
|
"message": job["message"],
|
||||||
"updated_at": job["updated_at"],
|
"updated_at": job["updated_at"],
|
||||||
|
# Die letzten Zeilen -- nur von dem, was gerade laeuft.
|
||||||
|
# Mitlesen heisst mitlesen: wer nach der Wartung wissen
|
||||||
|
# will, was war, oeffnet den Auftrag. Alles mitzuschicken
|
||||||
|
# hiesse, alle zwei Sekunden das gesamte Protokoll aller
|
||||||
|
# Geraete zu uebertragen.
|
||||||
|
"output": (
|
||||||
|
service.letzte_zeilen(str(job["output"] or ""))
|
||||||
|
if job["state"] in {"pending", "running"}
|
||||||
|
else ""
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for job in jobs
|
for job in jobs
|
||||||
],
|
],
|
||||||
@@ -187,7 +205,7 @@ def job_output(job_id: str):
|
|||||||
return render_template("tesm/_maintenance_output.html", job=job)
|
return render_template("tesm/_maintenance_output.html", job=job)
|
||||||
|
|
||||||
|
|
||||||
def _start(kind: str) -> None:
|
def _start(kind: str, befehl: str = "") -> None:
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
||||||
@@ -233,12 +251,27 @@ def _start(kind: str) -> None:
|
|||||||
actor_id=current_user.id,
|
actor_id=current_user.id,
|
||||||
action=kind,
|
action=kind,
|
||||||
target=str(device["name"]),
|
target=str(device["name"]),
|
||||||
detail=f"Auftrag {handle.id}",
|
# Beim Befehl gehoert sein Wortlaut hierher und nicht nur die
|
||||||
|
# Auftragsnummer: was gelaufen ist, muss aus dem Protokoll
|
||||||
|
# hervorgehen, auch wenn der Auftrag laengst aus dem Verlauf
|
||||||
|
# gerollt ist.
|
||||||
|
detail=f"Auftrag {handle.id}" + (f": {befehl}" if befehl else ""),
|
||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="notice",
|
severity="warning" if befehl else "notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
if kind == service.JOB_KIND_UPDATE:
|
if kind == service.JOB_KIND_COMMAND:
|
||||||
|
service.start_background(
|
||||||
|
service.run_command,
|
||||||
|
database=extension.database,
|
||||||
|
job_id=handle.id,
|
||||||
|
device=dict(device),
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
befehl=befehl,
|
||||||
|
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||||
|
)
|
||||||
|
elif kind == service.JOB_KIND_UPDATE:
|
||||||
service.start_background(
|
service.start_background(
|
||||||
service.run_update,
|
service.run_update,
|
||||||
database=extension.database,
|
database=extension.database,
|
||||||
@@ -289,3 +322,22 @@ def start_update():
|
|||||||
def start_reboot():
|
def start_reboot():
|
||||||
_start(service.JOB_KIND_REBOOT)
|
_start(service.JOB_KIND_REBOOT)
|
||||||
return redirect(url_for("maintenance.overview"))
|
return redirect(url_for("maintenance.overview"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/command")
|
||||||
|
@permission_required("servicing_command.execute")
|
||||||
|
@reauth_required
|
||||||
|
def start_command():
|
||||||
|
"""Eine Befehlszeile auf allen ausgewaehlten Clients.
|
||||||
|
|
||||||
|
Dieselbe Schwelle wie die Konsole: eigenes Recht **und** eine frische
|
||||||
|
Passwortbestaetigung. Wer hier etwas eintippt, tut es auf einem Dutzend
|
||||||
|
Maschinen gleichzeitig, und niemand sieht dabei zu.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
befehl = service.befehl_pruefen(request.form.get("befehl", ""))
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), "danger")
|
||||||
|
return redirect(url_for("maintenance.overview"))
|
||||||
|
_start(service.JOB_KIND_COMMAND, befehl=befehl)
|
||||||
|
return redirect(url_for("maintenance.overview"))
|
||||||
@@ -76,6 +76,16 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
|
|||||||
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
||||||
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
|
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
|
||||||
("uname -a", "Kernel und Architektur"),
|
("uname -a", "Kernel und Architektur"),
|
||||||
|
# Die beiden schreiben -- deshalb stehen sie am Ende und sagen es im Namen.
|
||||||
|
# Eine Vorlage fuellt nur das Eingabefeld; abgeschickt wird von Hand.
|
||||||
|
(
|
||||||
|
'echo "Encryption=AlwaysOff" | sudo tee -a /etc/vnc/config.d/common.custom',
|
||||||
|
"VNC: Streckenverschlüsselung abschalten (schreibt)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sudo systemctl restart vncserver-x11-serviced",
|
||||||
|
"VNC-Dienst neu starten (schreibt)",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,24 @@ SERVICING_TOOL = Resource(
|
|||||||
action_hints={A.EXECUTE: "Installation auslösen."},
|
action_hints={A.EXECUTE: "Installation auslösen."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Ein freier Befehl auf vielen Geraeten ist das Maechtigste, was diese
|
||||||
|
#: Anwendung kann -- und das Einzige, dessen Wirkung sie nicht kennt. Deshalb
|
||||||
|
#: ein eigenes Recht, das niemand nebenbei bekommt: wer Pakete aktualisieren
|
||||||
|
#: oder neu starten darf, tut etwas Umrissenes; wer hier etwas eintippt, tut
|
||||||
|
#: alles, was ein Konto auf dem Geraet tun kann. Die Vorlagen nehmen dem den
|
||||||
|
#: Alltag ab, nicht die Verantwortung.
|
||||||
|
SERVICING_COMMAND = Resource(
|
||||||
|
key="servicing_command",
|
||||||
|
label="Befehl auf mehreren Geräten",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Eine Befehlszeile über SSH, auf allen ausgewählten Clients",
|
||||||
|
action_hints={
|
||||||
|
A.READ: "Die Karte sehen – und damit, was zuletzt lief.",
|
||||||
|
A.EXECUTE: "Befehl absenden. Verlangt eine frische Passwortbestätigung; "
|
||||||
|
"jeder Auftrag steht mit seinem Wortlaut im Änderungsprotokoll.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
|
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
|
||||||
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
|
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
|
||||||
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
|
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
|
||||||
@@ -194,6 +212,7 @@ SERVICING_PAGE: tuple[str, ...] = (
|
|||||||
"servicing_update.read",
|
"servicing_update.read",
|
||||||
"servicing_reboot.read",
|
"servicing_reboot.read",
|
||||||
"servicing_tool.read",
|
"servicing_tool.read",
|
||||||
|
"servicing_command.read",
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
|
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
|
||||||
@@ -354,7 +373,7 @@ MAINTENANCE_AREA = Area(
|
|||||||
icon="wrench",
|
icon="wrench",
|
||||||
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||||
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
|
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||||
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
|
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_COMMAND, SERVICING_TOOL),
|
||||||
)
|
)
|
||||||
|
|
||||||
NETWORK_AREA = Area(
|
NETWORK_AREA = Area(
|
||||||
|
|||||||
@@ -44,6 +44,51 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
JOB_KIND_UPDATE = "maintenance.update"
|
JOB_KIND_UPDATE = "maintenance.update"
|
||||||
JOB_KIND_REBOOT = "maintenance.reboot"
|
JOB_KIND_REBOOT = "maintenance.reboot"
|
||||||
|
JOB_KIND_COMMAND = "maintenance.command"
|
||||||
|
|
||||||
|
#: Vorlagen fuer den Massenbefehl: Name, Befehl, Erklaerung.
|
||||||
|
#:
|
||||||
|
#: ``{sudo}`` wird zur Laufzeit ersetzt -- mit ``sudo -n``, wo es ohne Passwort
|
||||||
|
#: geht, sonst mit ``sudo -S`` und dem hinterlegten Passwort ueber stdin. Wer
|
||||||
|
#: eine Vorlage waehlt, sieht den Befehl im Feld und kann ihn aendern: eine
|
||||||
|
#: Vorlage ist ein Vorschlag, kein Automat.
|
||||||
|
BEFEHLSVORLAGEN: tuple[tuple[str, str, str], ...] = (
|
||||||
|
(
|
||||||
|
"VNC: Streckenverschlüsselung abschalten",
|
||||||
|
'echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom',
|
||||||
|
"Danach bietet RealVNC auch RA2ne und „ohne Authentifizierung“ an – nur damit "
|
||||||
|
"kommt eine Sitzung im Browser zustande. Die Strecke ist dann unverschlüsselt; "
|
||||||
|
"im Netz zwischen Server und Gerät ist sie es ohnehin.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"VNC-Dienst neu starten",
|
||||||
|
"{sudo} systemctl restart vncserver-x11-serviced",
|
||||||
|
"Räumt auch eine Sperre ab, die der Server nach zu vielen abgebrochenen "
|
||||||
|
"Verbindungsversuchen gesetzt hat.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"VNC: aktuelle Einstellungen zeigen",
|
||||||
|
"cat /etc/vnc/config.d/common.custom 2>/dev/null || echo '(keine eigene Konfiguration)'",
|
||||||
|
"Liest nur – gut, um vor dem Ändern zu sehen, was schon dasteht.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Zeit und Zeitzone",
|
||||||
|
"timedatectl",
|
||||||
|
"Liest nur.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Belegung der Dateisysteme",
|
||||||
|
"df -h",
|
||||||
|
"Liest nur.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Was in einer Befehlszeile nichts zu suchen hat. Nicht als Schutz vor einem
|
||||||
|
#: Menschen mit diesem Recht -- er darf ohnehin alles, was sein Konto auf dem
|
||||||
|
#: Geraet darf --, sondern gegen Unfaelle: ein Zeilenumbruch aus der
|
||||||
|
#: Zwischenablage macht aus einem Befehl unbemerkt zwei.
|
||||||
|
VERBOTENE_ZEICHEN = ("\n", "\r", "\x00")
|
||||||
|
MAX_BEFEHLSLAENGE = 500
|
||||||
|
|
||||||
MAX_OUTPUT_LINES = 400
|
MAX_OUTPUT_LINES = 400
|
||||||
CONNECT_TIMEOUT = 25
|
CONNECT_TIMEOUT = 25
|
||||||
@@ -231,7 +276,10 @@ def update_job(
|
|||||||
|
|
||||||
|
|
||||||
def list_jobs(
|
def list_jobs(
|
||||||
conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
|
||||||
|
limit: int = 60,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
placeholders = ",".join("?" * len(kinds))
|
placeholders = ",".join("?" * len(kinds))
|
||||||
return Database.all(
|
return Database.all(
|
||||||
@@ -447,6 +495,128 @@ def run_update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: So viele Zeilen gehen ins Mitlesen. Genug, um zu sehen, was gerade
|
||||||
|
#: passiert; wenig genug, dass eine Abfrage alle zwei Sekunden nichts kostet.
|
||||||
|
MITLESEN_ZEILEN = 40
|
||||||
|
|
||||||
|
|
||||||
|
def letzte_zeilen(ausgabe: str, anzahl: int = MITLESEN_ZEILEN) -> str:
|
||||||
|
"""Das Ende einer Ausgabe -- das ist der Teil, der gerade entsteht."""
|
||||||
|
zeilen = [zeile for zeile in ausgabe.splitlines() if zeile.strip()]
|
||||||
|
return "\n".join(zeilen[-anzahl:])
|
||||||
|
|
||||||
|
|
||||||
|
def befehl_pruefen(befehl: str) -> str:
|
||||||
|
"""Gibt den bereinigten Befehl zurueck -- oder wirft mit Klartext.
|
||||||
|
|
||||||
|
Eine eigene Funktion, weil genau diese Frage sich pruefen lassen muss,
|
||||||
|
ohne ein Geraet zu brauchen.
|
||||||
|
"""
|
||||||
|
sauber = (befehl or "").strip()
|
||||||
|
if not sauber:
|
||||||
|
raise ValueError("Es wurde kein Befehl eingegeben.")
|
||||||
|
if len(sauber) > MAX_BEFEHLSLAENGE:
|
||||||
|
raise ValueError(
|
||||||
|
f"Der Befehl ist zu lang (mehr als {MAX_BEFEHLSLAENGE} Zeichen). "
|
||||||
|
"Für längere Abläufe ist ein Skript auf dem Gerät der bessere Weg."
|
||||||
|
)
|
||||||
|
for zeichen in VERBOTENE_ZEICHEN:
|
||||||
|
if zeichen in sauber:
|
||||||
|
raise ValueError(
|
||||||
|
"Der Befehl enthält einen Zeilenumbruch. Hier läuft genau eine "
|
||||||
|
"Befehlszeile – alles danach würde stillschweigend mitlaufen."
|
||||||
|
)
|
||||||
|
return sauber
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
database: Any,
|
||||||
|
*,
|
||||||
|
job_id: str,
|
||||||
|
device: dict[str, Any],
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
befehl: str,
|
||||||
|
timeout: int,
|
||||||
|
) -> None:
|
||||||
|
"""Fuehrt **eine** Befehlszeile auf einem Geraet aus. Eigener Thread.
|
||||||
|
|
||||||
|
Bewusst ohne Deutung des Ergebnisses: was der Befehl tut, weiss der
|
||||||
|
Mensch, der ihn geschrieben hat. Festgehalten wird, was zurueckkam und mit
|
||||||
|
welchem Rueckgabewert -- mehr kann ein Werkzeug hier nicht beitragen, und
|
||||||
|
weniger waere zu wenig.
|
||||||
|
"""
|
||||||
|
name = str(device.get("name") or device.get("ip"))
|
||||||
|
|
||||||
|
def emit(line: str) -> None:
|
||||||
|
stamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
update_job(database, job_id, append_output=f"{stamp} {line}\n")
|
||||||
|
|
||||||
|
update_job(
|
||||||
|
database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with database.session() as conn:
|
||||||
|
transport = open_transport(
|
||||||
|
conn,
|
||||||
|
host=str(device["ip"]),
|
||||||
|
port=int(device.get("ssh_port") or 22),
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
except SshError as exc:
|
||||||
|
emit(f"FEHLER: {exc}")
|
||||||
|
update_job(database, job_id, state="failed", message=str(exc), finished=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
status = 1
|
||||||
|
try:
|
||||||
|
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
||||||
|
# Nur wer {sudo} benutzt, bekommt es auch: ein Befehl ohne diese Marke
|
||||||
|
# laeuft als der angemeldete Benutzer, und das ist die richtige
|
||||||
|
# Vorgabe -- Rechte holt man sich hier ausdruecklich.
|
||||||
|
ausgefuehrt = befehl.replace("{sudo}", sudo) if "{sudo}" in befehl else befehl
|
||||||
|
emit(f"$ {ausgefuehrt}")
|
||||||
|
update_job(database, job_id, progress=40, message="Befehl läuft")
|
||||||
|
status = _run_remote_command(
|
||||||
|
transport,
|
||||||
|
ausgefuehrt,
|
||||||
|
timeout=timeout,
|
||||||
|
on_line=emit,
|
||||||
|
stdin_text=sudo_stdin if "{sudo}" in befehl else "",
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - der Auftrag soll das Ergebnis tragen
|
||||||
|
emit(f"FEHLER: {exc}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
transport.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
emit(COMPLETION_MARKER)
|
||||||
|
update_job(
|
||||||
|
database,
|
||||||
|
job_id,
|
||||||
|
state="succeeded" if status == 0 else "failed",
|
||||||
|
progress=100,
|
||||||
|
message=(
|
||||||
|
"Befehl ausgeführt."
|
||||||
|
if status == 0
|
||||||
|
else f"Der Befehl endete mit Rückgabewert {status}."
|
||||||
|
),
|
||||||
|
finished=True,
|
||||||
|
)
|
||||||
|
with database.session() as conn, database.transaction(conn):
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor="system",
|
||||||
|
action="maintenance.command_finished",
|
||||||
|
target=name,
|
||||||
|
detail=f"Rückgabewert {status}",
|
||||||
|
severity="notice" if status == 0 else "warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reboot_windows(
|
def _reboot_windows(
|
||||||
database: Any,
|
database: Any,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -594,7 +594,16 @@ def run_sweep(
|
|||||||
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
||||||
# seinen Knopf nicht verlieren.
|
# seinen Knopf nicht verlieren.
|
||||||
return device, ergebnis, None
|
return device, ergebnis, None
|
||||||
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
|
try:
|
||||||
|
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
# Die Ueberwachung ist die Kernaufgabe, der Bildschirmzugriff eine
|
||||||
|
# Zugabe. Was hier schiefgeht -- ein Name, den der Resolver nicht
|
||||||
|
# verdaut, ein Netz, das seltsam antwortet --, darf niemals den
|
||||||
|
# ganzen Durchlauf kosten: sonst stuenden alle Geraete auf
|
||||||
|
# "unbekannt", und niemand brächte das mit VNC in Verbindung.
|
||||||
|
logger.warning("VNC-Pruefung fuer %s uebersprungen", device.get("name"), exc_info=True)
|
||||||
|
return device, ergebnis, None
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
||||||
results = list(pool.map(pruefen, devices))
|
results = list(pool.map(pruefen, devices))
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ def aufloesbar(name: str) -> bool:
|
|||||||
try:
|
try:
|
||||||
socket.getaddrinfo(name, None)
|
socket.getaddrinfo(name, None)
|
||||||
ergebnis = True
|
ergebnis = True
|
||||||
except OSError:
|
except Exception: # noqa: BLE001
|
||||||
|
# Nicht nur OSError: ein Name mit Sonderzeichen wirft UnicodeError,
|
||||||
|
# und der ist kein OSError. Aufloesbar ist er so oder so nicht.
|
||||||
ergebnis = False
|
ergebnis = False
|
||||||
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
||||||
return ergebnis
|
return ergebnis
|
||||||
|
|||||||
@@ -36,5 +36,7 @@
|
|||||||
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
|
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
|
||||||
<button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden
|
<button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden
|
||||||
title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button>
|
title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button>
|
||||||
|
<button class="btn btn--sm" type="button" data-rolle="taste-f5" hidden
|
||||||
|
title="F5 an das Gerät senden – der Browser des Arbeitsplatzes würde sonst diese Seite neu laden">F5</button>
|
||||||
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
|
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@@ -47,6 +47,48 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
|
|
||||||
<form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
|
<form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
{% if current_user.has('servicing_command.read') %}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('terminal', size=17) }} Massenbefehl</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Eine Befehlszeile über SSH, auf allen unten ausgewählten Clients – jeder als
|
||||||
|
eigener Auftrag mit eigener Ausgabe. <strong>{{ '{sudo}' }}</strong> im Befehl
|
||||||
|
wird ersetzt: mit <code>sudo -n</code>, wo es ohne Passwort geht, sonst mit dem
|
||||||
|
hinterlegten Passwort über stdin. Ohne diese Marke läuft der Befehl als das
|
||||||
|
angemeldete Konto.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card__body stack">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Vorlage</span>
|
||||||
|
<select class="select" id="befehlsvorlage">
|
||||||
|
<option value="">– eigene Eingabe –</option>
|
||||||
|
{% for name, befehl, erklaerung in befehlsvorlagen %}
|
||||||
|
<option value="{{ befehl }}" data-erklaerung="{{ erklaerung }}">{{ name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="field__hint" id="befehlserklaerung">
|
||||||
|
Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Befehl</span>
|
||||||
|
<input class="input mono" type="text" name="befehl" id="befehl"
|
||||||
|
maxlength="500" autocomplete="off"
|
||||||
|
placeholder='echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom'
|
||||||
|
{% if not current_user.has('servicing_command.execute') %}disabled{% endif %}>
|
||||||
|
<span class="field__hint">
|
||||||
|
Genau eine Zeile. Was der Befehl bewirkt, weiß der, der ihn schreibt – TESM
|
||||||
|
führt ihn aus und hält fest, was zurückkam.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="table-toolbar">
|
<div class="table-toolbar">
|
||||||
<label class="check">
|
<label class="check">
|
||||||
@@ -54,6 +96,14 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<span>Alle auswählen</span>
|
<span>Alle auswählen</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="table-toolbar__spacer"></span>
|
<span class="table-toolbar__spacer"></span>
|
||||||
|
{% if current_user.has('servicing_command.execute') %}
|
||||||
|
<button class="btn btn--sm" type="submit"
|
||||||
|
formaction="{{ url_for('maintenance.start_command') }}"
|
||||||
|
data-confirm="Den eingetragenen Befehl auf allen ausgewählten Geräten ausführen? Was er bewirkt, entscheidet er selbst – TESM führt ihn nur aus."
|
||||||
|
data-confirm-title="Befehl senden" data-confirm-ok="Senden" data-confirm-style="danger">
|
||||||
|
{{ icon('terminal', size=15) }} Befehl senden
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if current_user.has('servicing_update.execute') %}
|
{% if current_user.has('servicing_update.execute') %}
|
||||||
<button class="btn btn--sm btn--primary" type="submit"
|
<button class="btn btn--sm btn--primary" type="submit"
|
||||||
formaction="{{ url_for('maintenance.start_update') }}"
|
formaction="{{ url_for('maintenance.start_update') }}"
|
||||||
@@ -95,9 +145,19 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<span class="sr-only">{{ device.name }} auswählen</span>
|
<span class="sr-only">{{ device.name }} auswählen</span>
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="fw-600">{{ device.name }}</td>
|
<td class="fw-600">
|
||||||
|
{# Mitlesen, ohne die Seite zu verlassen -- so war es in der
|
||||||
|
Fassung 1.2.8, und es fehlte seither. Die Zeile darunter
|
||||||
|
klappt auf, sobald für dieses Gerät etwas läuft. #}
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button"
|
||||||
|
data-ausgabe-schalter="{{ device.name }}"
|
||||||
|
title="Ausgabe dieses Geräts mitlesen">
|
||||||
|
{{ icon('chevron-right', size=13) }}
|
||||||
|
</button>
|
||||||
|
{{ device.name }}
|
||||||
|
</td>
|
||||||
<td class="mono fs-sm">{{ device.ip }}</td>
|
<td class="mono fs-sm">{{ device.ip }}</td>
|
||||||
<td>
|
<td data-zustand="{{ device.id }}">
|
||||||
{% if device.state == 'online' %}{{ pill('online', 'online') }}
|
{% if device.state == 'online' %}{{ pill('online', 'online') }}
|
||||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||||
@@ -129,6 +189,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr data-ausgabe-zeile="{{ device.name }}" hidden>
|
||||||
|
<td colspan="7" class="p-0">
|
||||||
|
<pre class="job-output" data-ausgabe="{{ device.name }}">Noch keine Ausgabe.</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -193,6 +258,18 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script type="module" nonce="{{ csp_nonce }}">
|
<script type="module" nonce="{{ csp_nonce }}">
|
||||||
|
// Eine Vorlage füllt das Feld und erklärt sich -- abgeschickt wird von Hand.
|
||||||
|
const vorlage = document.getElementById("befehlsvorlage");
|
||||||
|
const befehl = document.getElementById("befehl");
|
||||||
|
const erklaerung = document.getElementById("befehlserklaerung");
|
||||||
|
vorlage?.addEventListener("change", () => {
|
||||||
|
const gewaehlt = vorlage.selectedOptions[0];
|
||||||
|
if (vorlage.value) befehl.value = vorlage.value;
|
||||||
|
erklaerung.textContent =
|
||||||
|
gewaehlt?.dataset.erklaerung ||
|
||||||
|
"Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.";
|
||||||
|
});
|
||||||
|
|
||||||
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||||
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
||||||
// versehentlich das falsche Gerät erwischt.
|
// versehentlich das falsche Gerät erwischt.
|
||||||
@@ -205,14 +282,74 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
.forEach((box) => (box.checked = all.checked));
|
.forEach((box) => (box.checked = all.checked));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auftragsliste nachladen, solange etwas läuft.
|
// Mitlesen: die Zeile unter dem Gerät zeigt, was dort gerade passiert.
|
||||||
|
//
|
||||||
|
// Vorher lud diese Seite sich alle zwei Sekunden komplett neu, solange etwas
|
||||||
|
// lief -- das verwarf jede Auswahl, jeden Tastendruck im Befehlsfeld und
|
||||||
|
// sprang nach oben. Jetzt kommen nur die letzten Zeilen, und die Seite
|
||||||
|
// bleibt stehen.
|
||||||
|
const schalter = Array.from(document.querySelectorAll("[data-ausgabe-schalter]"));
|
||||||
|
const offen = new Set();
|
||||||
|
schalter.forEach((knopf) => {
|
||||||
|
knopf.addEventListener("click", () => {
|
||||||
|
const name = knopf.dataset.ausgabeSchalter;
|
||||||
|
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||||
|
if (!zeile) return;
|
||||||
|
zeile.hidden = !zeile.hidden;
|
||||||
|
if (zeile.hidden) offen.delete(name);
|
||||||
|
else offen.add(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function zeigen(name, text) {
|
||||||
|
const feld = document.querySelector(`[data-ausgabe="${CSS.escape(name)}"]`);
|
||||||
|
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||||
|
if (!feld || !zeile) return;
|
||||||
|
const unten = feld.scrollTop + feld.clientHeight >= feld.scrollHeight - 24;
|
||||||
|
feld.textContent = text || "Noch keine Ausgabe.";
|
||||||
|
if (unten) feld.scrollTop = feld.scrollHeight;
|
||||||
|
// Was läuft, klappt von selbst auf -- wer zusieht, will nicht erst suchen.
|
||||||
|
if (text && zeile.hidden && !offen.has(name)) zeile.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let liefEtwas = false;
|
||||||
async function poll() {
|
async function poll() {
|
||||||
|
let weiter = 10000;
|
||||||
try {
|
try {
|
||||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
|
||||||
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
weiter = state.poll_ms || 10000;
|
||||||
|
state.jobs.forEach((job) => {
|
||||||
|
if (job.output) zeigen(job.subject, job.output);
|
||||||
|
});
|
||||||
|
// Erreichbarkeit mitziehen -- sonst steht die Seite mit dem Stand ihres
|
||||||
|
// Aufbaus da, und wer nach einem Neustart auf die Rückkehr wartet, sieht
|
||||||
|
// "unbekannt", bis er von Hand neu lädt.
|
||||||
|
(state.devices || []).forEach((geraet) => {
|
||||||
|
const zelle = document.querySelector(`[data-zustand="${geraet.id}"]`);
|
||||||
|
if (!zelle) return;
|
||||||
|
const [text, art] =
|
||||||
|
geraet.state === "online"
|
||||||
|
? ["online", "pill--online"]
|
||||||
|
: geraet.state === "offline"
|
||||||
|
? ["offline", "pill--offline"]
|
||||||
|
: ["unbekannt", "pill--warning"];
|
||||||
|
const pille = zelle.querySelector(".pill");
|
||||||
|
if (pille && pille.textContent.trim() !== text) {
|
||||||
|
pille.textContent = text;
|
||||||
|
pille.className = `pill ${art}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Erst wenn der letzte Auftrag durch ist, einmal neu laden: dann stimmt
|
||||||
|
// auch die Auftragsliste daneben.
|
||||||
|
if (liefEtwas && !state.running) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
liefEtwas = state.running;
|
||||||
} catch {
|
} catch {
|
||||||
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
||||||
}
|
}
|
||||||
|
window.setTimeout(poll, weiter);
|
||||||
}
|
}
|
||||||
poll();
|
poll();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.8.13"
|
version = "2.8.14"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1275,6 +1275,24 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mitlesen auf der Wartungsseite: die Ausgabe eines laufenden Auftrags,
|
||||||
|
direkt unter seinem Geraet. Dunkler Grund wie in jeder Konsole -- was
|
||||||
|
dort steht, kommt von einer fremden Maschine und nicht aus dieser
|
||||||
|
Oberflaeche. */
|
||||||
|
.job-output {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
background: #101216;
|
||||||
|
color: #d7dbe0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------- #
|
/* ---------------------------------------------------------------------- #
|
||||||
Bildschirmsitzung (VNC)
|
Bildschirmsitzung (VNC)
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const RA2NE = 6;
|
|||||||
//: einen Knopf brauchen. X11-Keysyms.
|
//: einen Knopf brauchen. X11-Keysyms.
|
||||||
const ALT_LINKS = 0xffe9;
|
const ALT_LINKS = 0xffe9;
|
||||||
const F4 = 0xffc1;
|
const F4 = 0xffc1;
|
||||||
|
const F5 = 0xffc2;
|
||||||
|
|
||||||
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|||||||
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
|
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
|
||||||
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
|
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
|
||||||
*/
|
*/
|
||||||
export function starteSitzung(wurzel, quelle) {
|
export function starteSitzung(wurzel, quelle, { beiEnde = null } = {}) {
|
||||||
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
|
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
|
||||||
const leinwand = teil("leinwand");
|
const leinwand = teil("leinwand");
|
||||||
const zeile = teil("status");
|
const zeile = teil("status");
|
||||||
@@ -295,6 +296,10 @@ export function starteSitzung(wurzel, quelle) {
|
|||||||
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
||||||
// dass TESM wegen Untätigkeit getrennt hat.
|
// dass TESM wegen Untätigkeit getrennt hat.
|
||||||
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
|
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
|
||||||
|
// Im Fenster gibt es nach dem Trennen nichts mehr zu sehen: ein leerer
|
||||||
|
// Rahmen, den man wegklicken muss. Bei einem **Abbruch** bleibt es
|
||||||
|
// stehen -- dort steht der Grund, und der ist der ganze Zweck.
|
||||||
|
beiEnde?.();
|
||||||
} else if (!stand && naechsterVersuch()) {
|
} else if (!stand && naechsterVersuch()) {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
@@ -374,6 +379,13 @@ export function starteSitzung(wurzel, quelle) {
|
|||||||
rfb.sendKey(F4, "F4", false);
|
rfb.sendKey(F4, "F4", false);
|
||||||
rfb.sendKey(ALT_LINKS, "AltLeft", false);
|
rfb.sendKey(ALT_LINKS, "AltLeft", false);
|
||||||
});
|
});
|
||||||
|
// F5 faengt der Browser des Arbeitsplatzes ab -- er wuerde diese Seite neu
|
||||||
|
// laden statt das Bild auf dem Geraet.
|
||||||
|
teil("taste-f5")?.addEventListener("click", () => {
|
||||||
|
if (!rfb) return;
|
||||||
|
rfb.sendKey(F5, "F5", true);
|
||||||
|
rfb.sendKey(F5, "F5", false);
|
||||||
|
});
|
||||||
|
|
||||||
sitzungsknoepfe(false);
|
sitzungsknoepfe(false);
|
||||||
melden("Wird vorbereitet …");
|
melden("Wird vorbereitet …");
|
||||||
@@ -442,7 +454,9 @@ if (fenster) {
|
|||||||
titel.textContent = knopf.dataset.vncName || "Bildschirm";
|
titel.textContent = knopf.dataset.vncName || "Bildschirm";
|
||||||
if (typeof fenster.showModal === "function") fenster.showModal();
|
if (typeof fenster.showModal === "function") fenster.showModal();
|
||||||
else fenster.setAttribute("open", "");
|
else fenster.setAttribute("open", "");
|
||||||
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen);
|
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen, {
|
||||||
|
beiEnde: () => fenster.close(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
|
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
|
||||||
|
|||||||
Reference in new issue
Block a user