Version 2.8.24: Massenbefehl, Mitlesen, F5
This commit is contained in:
1 parent
914609b758
commit
3c64abb540
12 files changed
+451
-18
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.23"
|
||||
version = "2.8.24"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -18,7 +18,7 @@ from __future__ import annotations
|
||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||
from tesm_core.security.sessions import fragment
|
||||
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.db import Database
|
||||
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("/")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
def overview():
|
||||
def overview(): # noqa: C901 - eine Seite, drei Aufgaben
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/maintenance.html",
|
||||
@@ -89,6 +89,7 @@ def overview():
|
||||
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
|
||||
windows_tool_missing=_windows_tool_missing(conn),
|
||||
windows_package=windowsops.REQUIRED_PACKAGE,
|
||||
befehlsvorlagen=service.BEFEHLSVORLAGEN,
|
||||
)
|
||||
|
||||
|
||||
@@ -150,6 +151,13 @@ def status():
|
||||
{
|
||||
"running": running,
|
||||
"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": [
|
||||
{
|
||||
"id": job["id"],
|
||||
@@ -159,6 +167,16 @@ def status():
|
||||
"progress": job["progress"],
|
||||
"message": job["message"],
|
||||
"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
|
||||
],
|
||||
@@ -187,7 +205,7 @@ def job_output(job_id: str):
|
||||
return render_template("tesm/_maintenance_output.html", job=job)
|
||||
|
||||
|
||||
def _start(kind: str) -> None:
|
||||
def _start(kind: str, befehl: str = "") -> None:
|
||||
extension = core()
|
||||
conn = db()
|
||||
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,
|
||||
action=kind,
|
||||
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(),
|
||||
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.run_update,
|
||||
database=extension.database,
|
||||
@@ -289,3 +322,22 @@ def start_update():
|
||||
def start_reboot():
|
||||
_start(service.JOB_KIND_REBOOT)
|
||||
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"),
|
||||
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
|
||||
("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."},
|
||||
)
|
||||
|
||||
#: 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
|
||||
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
|
||||
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
|
||||
@@ -194,6 +212,7 @@ SERVICING_PAGE: tuple[str, ...] = (
|
||||
"servicing_update.read",
|
||||
"servicing_reboot.read",
|
||||
"servicing_tool.read",
|
||||
"servicing_command.read",
|
||||
)
|
||||
|
||||
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
|
||||
@@ -354,7 +373,7 @@ MAINTENANCE_AREA = Area(
|
||||
icon="wrench",
|
||||
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||
"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(
|
||||
|
||||
@@ -44,6 +44,51 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
JOB_KIND_UPDATE = "maintenance.update"
|
||||
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
|
||||
CONNECT_TIMEOUT = 25
|
||||
@@ -231,7 +276,10 @@ def update_job(
|
||||
|
||||
|
||||
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]]:
|
||||
placeholders = ",".join("?" * len(kinds))
|
||||
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(
|
||||
database: Any,
|
||||
*,
|
||||
|
||||
@@ -594,7 +594,16 @@ def run_sweep(
|
||||
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
||||
# seinen Knopf nicht verlieren.
|
||||
return device, ergebnis, None
|
||||
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:
|
||||
results = list(pool.map(pruefen, devices))
|
||||
|
||||
@@ -132,7 +132,9 @@ def aufloesbar(name: str) -> bool:
|
||||
try:
|
||||
socket.getaddrinfo(name, None)
|
||||
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
|
||||
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
@@ -36,5 +36,7 @@
|
||||
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
|
||||
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>
|
||||
{% 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') }}">
|
||||
<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">
|
||||
<div class="table-toolbar">
|
||||
<label class="check">
|
||||
@@ -54,6 +96,14 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<span>Alle auswählen</span>
|
||||
</label>
|
||||
<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') %}
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
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>
|
||||
</label>
|
||||
</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>
|
||||
<td data-zustand="{{ device.id }}">
|
||||
{% if device.state == 'online' %}{{ pill('online', 'online') }}
|
||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||
@@ -129,6 +189,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
{% endif %}
|
||||
</td>
|
||||
</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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -193,6 +258,18 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
|
||||
{% block scripts %}
|
||||
<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
|
||||
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
||||
// 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));
|
||||
});
|
||||
|
||||
// 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() {
|
||||
let weiter = 10000;
|
||||
try {
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
|
||||
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 {
|
||||
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
||||
}
|
||||
window.setTimeout(poll, weiter);
|
||||
}
|
||||
poll();
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1275,6 +1275,24 @@
|
||||
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)
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ const RA2NE = 6;
|
||||
//: einen Knopf brauchen. X11-Keysyms.
|
||||
const ALT_LINKS = 0xffe9;
|
||||
const F4 = 0xffc1;
|
||||
const F5 = 0xffc2;
|
||||
|
||||
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.
|
||||
* 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 leinwand = teil("leinwand");
|
||||
const zeile = teil("status");
|
||||
@@ -295,6 +296,10 @@ export function starteSitzung(wurzel, quelle) {
|
||||
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
||||
// dass TESM wegen Untätigkeit getrennt hat.
|
||||
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()) {
|
||||
return;
|
||||
} else {
|
||||
@@ -374,6 +379,13 @@ export function starteSitzung(wurzel, quelle) {
|
||||
rfb.sendKey(F4, "F4", 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);
|
||||
melden("Wird vorbereitet …");
|
||||
@@ -442,7 +454,9 @@ if (fenster) {
|
||||
titel.textContent = knopf.dataset.vncName || "Bildschirm";
|
||||
if (typeof fenster.showModal === "function") fenster.showModal();
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user