Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c40ccfbd | ||
|
|
0ac651a1b9 | ||
|
|
4275c84a6c | ||
|
|
4f4310bfe4 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.8.8"
|
version = "2.8.12"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -39,11 +39,21 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
|
|
||||||
from ..services import inventory, restart
|
from ..services import inventory, restart
|
||||||
|
|
||||||
|
from ..services import vnc as vnc_dienst
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
counts = monitor.dashboard_counts(conn)
|
counts = monitor.dashboard_counts(conn)
|
||||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
rows = inventory.list_devices(conn, only_active=not authenticated)
|
||||||
|
|
||||||
detailed = authenticated and current_user.has("devices.read")
|
detailed = authenticated and current_user.has("devices.read")
|
||||||
|
# Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe,
|
||||||
|
# und ohne das Recht wird gar nicht erst nachgesehen.
|
||||||
|
vnc_erlaubt = detailed and current_user.has("device_vnc.execute")
|
||||||
|
vnc_port = (
|
||||||
|
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
|
||||||
|
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
|
||||||
|
else 0
|
||||||
|
)
|
||||||
tiles: list[dict[str, Any]] = []
|
tiles: list[dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
@@ -74,6 +84,23 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
maintenance_active=_maintenance_active(),
|
maintenance_active=_maintenance_active(),
|
||||||
darf=current_user.has,
|
darf=current_user.has,
|
||||||
),
|
),
|
||||||
|
# Die fertige Adresse, nicht nur ein Schalter: der Knopf
|
||||||
|
# auf der Kachel soll die Verbindung oeffnen und nicht
|
||||||
|
# erst eine Datei ablegen, die jemand anklicken muss.
|
||||||
|
#
|
||||||
|
# Beides -- ob dort ein Server antwortet und wohin gefragt
|
||||||
|
# wurde -- steht aus dem letzten Durchlauf in der
|
||||||
|
# Datenbank. Die Uebersicht zeigt viele Kacheln und laedt
|
||||||
|
# sich selbst nach; ein Verbindungsversuch oder eine
|
||||||
|
# Namensaufloesung je Kachel und Aufbau waere genau die
|
||||||
|
# Art Hintergrundlast, die niemand bestellt hat.
|
||||||
|
"vnc": (
|
||||||
|
vnc_dienst.Zugang(
|
||||||
|
ziel=str(row["vnc_target"]), port=vnc_port, erreichbar=True
|
||||||
|
).adresse
|
||||||
|
if vnc_port and row["vnc_open"] and row["vnc_target"]
|
||||||
|
else ""
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
tiles.append(entry)
|
tiles.append(entry)
|
||||||
|
|||||||
@@ -11,14 +11,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
Response,
|
||||||
|
abort,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
from tesm_core.auth.models import current_user, permission_required, reauth_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
|
||||||
|
|
||||||
from ..services import inventory, monitor, restart, ssh
|
from ..services import inventory, monitor, restart, ssh, vnc
|
||||||
|
|
||||||
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
||||||
|
|
||||||
@@ -360,6 +369,69 @@ def detail(device_id: int):
|
|||||||
host_keys=host_keys,
|
host_keys=host_keys,
|
||||||
method_labels=monitor.METHOD_LABELS,
|
method_labels=monitor.METHOD_LABELS,
|
||||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
||||||
|
vnc=_vnc_zugang(conn, device),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
|
||||||
|
"""Der Bildschirmzugriff dieses Clients -- frisch gefragt, nicht geraten.
|
||||||
|
|
||||||
|
Die Kacheln der Statusuebersicht nehmen den Stand aus dem letzten
|
||||||
|
Ueberwachungsdurchlauf; hier geht es um **ein** Geraet, also fragt die Seite
|
||||||
|
selbst nach (ein halbe Sekunde Zeitlimit, und nur wenn der Client gerade
|
||||||
|
erreichbar ist). Das Ergebnis wird gleich festgehalten -- dann stimmt auch
|
||||||
|
die Kachel, ohne auf den naechsten Durchlauf zu warten.
|
||||||
|
"""
|
||||||
|
extension = core()
|
||||||
|
if not current_user.has("device_vnc.execute"):
|
||||||
|
return None
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
return None
|
||||||
|
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||||
|
online = str(device.get("state") or "") == "online" and not device.get("deleted_at")
|
||||||
|
zugang = vnc.zugang(device, port=port, pruefen=online)
|
||||||
|
if online:
|
||||||
|
with transaction():
|
||||||
|
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
|
||||||
|
return zugang
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<int:device_id>/vnc")
|
||||||
|
@permission_required("device_vnc.execute")
|
||||||
|
def vnc_connection(device_id: int):
|
||||||
|
"""Die Verbindungsdatei -- drei Zeilen, sonst nichts.
|
||||||
|
|
||||||
|
Kein Passwort, kein Benutzername, keine Verschluesselungsvorgabe: was beim
|
||||||
|
Verbinden verlangt wird, entscheidet der VNC-Server auf dem Geraet. Die
|
||||||
|
Datei traegt nur, wohin es geht und wie die Verbindung im Viewer heisst.
|
||||||
|
"""
|
||||||
|
conn = db()
|
||||||
|
device = inventory.get_device(conn, device_id)
|
||||||
|
if device is None:
|
||||||
|
abort(404)
|
||||||
|
extension = core()
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
abort(404)
|
||||||
|
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||||
|
ziel = vnc.ziel(device)
|
||||||
|
if not ziel:
|
||||||
|
abort(404)
|
||||||
|
with transaction():
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
"device.vnc_opened",
|
||||||
|
str(device["name"]),
|
||||||
|
f"Verbindungsdatei für {ziel}:{port}",
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
vnc.verbindungsdatei(device, port=port),
|
||||||
|
mimetype="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
|
||||||
|
# Eine Verbindungsdatei ist kein Dokument, das ein Proxy aufheben
|
||||||
|
# soll -- und sie ist billig genug, sie jedes Mal neu zu erzeugen.
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ def system():
|
|||||||
"monitor_failures_before_restart",
|
"monitor_failures_before_restart",
|
||||||
"monitor_auto_restart",
|
"monitor_auto_restart",
|
||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
|
"vnc_enabled",
|
||||||
|
"vnc_port",
|
||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
"terminal_clients_enabled",
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
@@ -143,6 +145,8 @@ def system_save():
|
|||||||
"monitor_failures_before_restart",
|
"monitor_failures_before_restart",
|
||||||
"monitor_auto_restart",
|
"monitor_auto_restart",
|
||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
|
"vnc_enabled",
|
||||||
|
"vnc_port",
|
||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
"terminal_clients_enabled",
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
@@ -305,7 +309,7 @@ def run_check_now():
|
|||||||
extension = core()
|
extension = core()
|
||||||
|
|
||||||
def worker() -> None:
|
def worker() -> None:
|
||||||
from ..services import inventory, monitor
|
from ..services import inventory, monitor, vnc
|
||||||
|
|
||||||
allowed = extension.license is None or extension.license.feature_allowed(
|
allowed = extension.license is None or extension.license.feature_allowed(
|
||||||
"auto_poe_restart"
|
"auto_poe_restart"
|
||||||
@@ -315,6 +319,7 @@ def run_check_now():
|
|||||||
live=extension.registry["live_log"],
|
live=extension.registry["live_log"],
|
||||||
keystore=extension.keystore,
|
keystore=extension.keystore,
|
||||||
license_allows_auto_restart=allowed,
|
license_allows_auto_restart=allowed,
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||||
)
|
)
|
||||||
|
|
||||||
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ def cmd_restore_signatures(_args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
def cmd_check_now(_args: argparse.Namespace) -> int:
|
def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||||
app, extension = _app_and_core()
|
app, extension = _app_and_core()
|
||||||
from .services import inventory, monitor
|
from .services import inventory, monitor, vnc
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
allowed = extension.license is None or extension.license.feature_allowed(
|
allowed = extension.license is None or extension.license.feature_allowed(
|
||||||
@@ -230,6 +230,7 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
|
|||||||
live=extension.registry["live_log"],
|
live=extension.registry["live_log"],
|
||||||
keystore=extension.keystore,
|
keystore=extension.keystore,
|
||||||
license_allows_auto_restart=allowed,
|
license_allows_auto_restart=allowed,
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
||||||
|
|||||||
@@ -78,6 +78,26 @@ DEVICE_RESTART_RPC = Resource(
|
|||||||
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
|
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Bildschirmzugriff ist **nicht** dasselbe wie die Konsole, auch wenn beides
|
||||||
|
#: "auf das Geraet" fuehrt: hier sieht jemand den Bildschirm, wie er ist --
|
||||||
|
#: samt dem, was ein Mensch dort gerade offen hat. Das ist eine eigene
|
||||||
|
#: Entscheidung, also eine eigene Zeile. Sie wird **niemandem** mitgegeben:
|
||||||
|
#: keine Migration setzt sie, auch nicht bei denen, die schon eine Konsole
|
||||||
|
#: oeffnen duerfen. Wer sie haben soll, bekommt sie in der Matrix -- einmal,
|
||||||
|
#: sichtbar, mit Zeile im Aenderungsprotokoll.
|
||||||
|
DEVICE_VNC = Resource(
|
||||||
|
key="device_vnc",
|
||||||
|
parent="devices",
|
||||||
|
label="Clients: Bildschirm (VNC)",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Verbindungsdatei bzw. vnc://-Adresse für den Bildschirm des Clients",
|
||||||
|
action_hints={
|
||||||
|
A.READ: "Den Knopf sehen, wo ein VNC-Server antwortet.",
|
||||||
|
A.EXECUTE: "Verbindung öffnen. Die Datei enthält nur Ziel und Namen – "
|
||||||
|
"was beim Verbinden verlangt wird, entscheidet der VNC-Server auf dem Gerät.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
|
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
|
||||||
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
||||||
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
||||||
@@ -316,6 +336,7 @@ MONITORING_AREA = Area(
|
|||||||
DEVICE_RESTART_SSH,
|
DEVICE_RESTART_SSH,
|
||||||
DEVICE_RESTART_RPC,
|
DEVICE_RESTART_RPC,
|
||||||
DEVICE_CONSOLE,
|
DEVICE_CONSOLE,
|
||||||
|
DEVICE_VNC,
|
||||||
SWITCHES,
|
SWITCHES,
|
||||||
SWITCH_CONSOLE,
|
SWITCH_CONSOLE,
|
||||||
HOSTKEYS,
|
HOSTKEYS,
|
||||||
|
|||||||
@@ -490,6 +490,31 @@ def eingefrorene_vorlagen(
|
|||||||
return vorlagen
|
return vorlagen
|
||||||
|
|
||||||
|
|
||||||
|
# Version 10 ----------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Wo antwortet ein VNC-Server? Die Frage beantwortet der Ueberwachungs-
|
||||||
|
# durchlauf mit, der ohnehin jedes Geraet anfasst -- einmal je Intervall statt
|
||||||
|
# einmal je Seitenaufruf. Die Kacheln der Statusuebersicht koennen den Knopf
|
||||||
|
# damit zeigen, ohne dass das Rendern der Seite ein Dutzend Verbindungen
|
||||||
|
# aufbaut.
|
||||||
|
V10 = """
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_open INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_checked_at TEXT;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Version 11 ----------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Nicht nur *ob* dort ein VNC-Server antwortet, sondern *wohin* gefragt wurde.
|
||||||
|
# Die Kacheln der Statusuebersicht verlinken die Verbindung unmittelbar
|
||||||
|
# (vnc://hap02); das Ziel dafuer aus dem Namen neu herzuleiten hiesse, bei
|
||||||
|
# jedem Seitenaufbau je Kachel eine Namensaufloesung zu versuchen. Der
|
||||||
|
# Durchlauf hat sie ohnehin schon gemacht -- also schreibt er sie hin.
|
||||||
|
V11 = """
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_target TEXT NOT NULL DEFAULT '';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||||
|
|
||||||
@@ -532,4 +557,6 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||||
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||||
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||||
|
Migration(version=10, name="vnc-erreichbarkeit", sql=V10),
|
||||||
|
Migration(version=11, name="vnc-ziel", sql=V11),
|
||||||
)
|
)
|
||||||
@@ -542,7 +542,8 @@ def list_devices(
|
|||||||
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
|
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
|
||||||
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
|
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
|
||||||
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
|
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
|
||||||
st.fail_count
|
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
|
||||||
|
COALESCE(st.vnc_target, '') AS vnc_target
|
||||||
FROM devices d
|
FROM devices d
|
||||||
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
||||||
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
||||||
|
|||||||
@@ -507,6 +507,23 @@ class SweepReport:
|
|||||||
duration_ms: int = 0
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def merke_vnc(
|
||||||
|
conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = ""
|
||||||
|
) -> None:
|
||||||
|
"""Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel.
|
||||||
|
|
||||||
|
Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar
|
||||||
|
verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und
|
||||||
|
Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal
|
||||||
|
geschrieben, von dem, der ohnehin gefragt hat.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? "
|
||||||
|
"WHERE device_id=?",
|
||||||
|
(1 if offen else 0, _now(), ziel, device_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_sweep(
|
def run_sweep(
|
||||||
*,
|
*,
|
||||||
database: Any,
|
database: Any,
|
||||||
@@ -514,13 +531,21 @@ def run_sweep(
|
|||||||
keystore: Any,
|
keystore: Any,
|
||||||
license_allows_auto_restart: bool,
|
license_allows_auto_restart: bool,
|
||||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||||
|
vnc_port: int = 0,
|
||||||
) -> SweepReport:
|
) -> SweepReport:
|
||||||
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
||||||
|
|
||||||
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
||||||
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
||||||
keine halb geschriebenen Durchlaeufe.
|
keine halb geschriebenen Durchlaeufe.
|
||||||
|
|
||||||
|
``vnc_port`` (0 = aus) haengt eine zweite, sehr kurze Frage an jedes
|
||||||
|
erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert
|
||||||
|
hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal
|
||||||
|
je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten.
|
||||||
"""
|
"""
|
||||||
|
from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier
|
||||||
|
|
||||||
report = SweepReport()
|
report = SweepReport()
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
|
||||||
@@ -531,15 +556,29 @@ def run_sweep(
|
|||||||
report.duration_ms = int((time.monotonic() - started) * 1000)
|
report.duration_ms = int((time.monotonic() - started) * 1000)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
def pruefen(
|
||||||
|
device: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], PingResult, tuple[bool, str] | None]:
|
||||||
|
ergebnis = ping(str(device["ip"]))
|
||||||
|
if not vnc_port or not ergebnis.ok:
|
||||||
|
# Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt
|
||||||
|
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
||||||
|
# seinen Knopf nicht verlieren.
|
||||||
|
return device, ergebnis, None
|
||||||
|
ziel = vnc_dienst.ziel(device)
|
||||||
|
return device, ergebnis, (vnc_dienst.erreichbar(ziel, vnc_port), ziel)
|
||||||
|
|
||||||
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(lambda device: (device, ping(str(device["ip"]))), devices))
|
results = list(pool.map(pruefen, devices))
|
||||||
|
|
||||||
restart_candidates: list[dict[str, Any]] = []
|
restart_candidates: list[dict[str, Any]] = []
|
||||||
with database.session() as conn:
|
with database.session() as conn:
|
||||||
with database.transaction(conn):
|
with database.transaction(conn):
|
||||||
for device, result in results:
|
for device, result, vnc_offen in results:
|
||||||
report.checked += 1
|
report.checked += 1
|
||||||
previous, current = record_status(conn, int(device["id"]), result)
|
previous, current = record_status(conn, int(device["id"]), result)
|
||||||
|
if vnc_offen is not None:
|
||||||
|
merke_vnc(conn, int(device["id"]), vnc_offen[0], vnc_offen[1])
|
||||||
if result.ok:
|
if result.ok:
|
||||||
report.online += 1
|
report.online += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Bildschirmzugriff per VNC: Ziel bestimmen, Erreichbarkeit pruefen, Datei bauen.
|
||||||
|
|
||||||
|
Auf den HAPs (Raspberry Pis) laeuft ein VNC-Server. TESM kennt von jedem
|
||||||
|
Client Namen und Adresse -- daraus laesst sich die Verbindung erzeugen, statt
|
||||||
|
sie in jedem Viewer von Hand zu pflegen.
|
||||||
|
|
||||||
|
Zwei Wege, beide aus denselben zwei Angaben:
|
||||||
|
|
||||||
|
* eine **Verbindungsdatei** (``.vnc``), wie der RealVNC Viewer sie schreibt.
|
||||||
|
Sie enthaelt genau drei Zeilen -- ``ConnMethod``, ``FriendlyName``, ``Host``
|
||||||
|
-- und **nichts weiter**. Jede zusaetzliche Angabe ist eine Gelegenheit,
|
||||||
|
etwas zu verlangen, das hier niemand verlangen soll: keine
|
||||||
|
Verschluesselungsvorgabe, keine Identitaetspruefung, kein Benutzername, kein
|
||||||
|
Passwortfeld. Was am Ende gefragt wird, entscheidet ohnehin der VNC-Server
|
||||||
|
auf dem Geraet; die Datei fragt von sich aus nichts.
|
||||||
|
* eine **Adresse** (``vnc://host``) fuer den Fall, dass der Viewer als
|
||||||
|
Handler registriert ist -- ein Klick statt Herunterladen und Oeffnen.
|
||||||
|
|
||||||
|
Der Zugang erscheint nur dort, wo wirklich ein VNC-Server antwortet
|
||||||
|
(:func:`erreichbar`). Das ist der Unterschied zu einem Schalter am Geraet:
|
||||||
|
niemand muss ihn pflegen, und er zeigt nie ins Leere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .monitor import tcp_reachable
|
||||||
|
|
||||||
|
#: Vorgabeport von VNC (Anzeige :0). Ueberschreibbar in den Einstellungen.
|
||||||
|
STANDARD_PORT = 5900
|
||||||
|
|
||||||
|
#: So kurz, dass die Detailseite es nicht merkt, und so lang, dass ein Geraet
|
||||||
|
#: im selben Netz antworten kann. Gefragt wird ohnehin nur, wenn der Client
|
||||||
|
#: gerade als erreichbar gilt.
|
||||||
|
ZEITLIMIT = 0.6
|
||||||
|
|
||||||
|
#: Ein Name, den ein Rechner aufloesen kann: Buchstaben, Ziffern, Bindestrich,
|
||||||
|
#: Punkt als Trenner. "HAP 02" oder "Kamera Eingang" ist keiner -- dann bleibt
|
||||||
|
#: die IP-Adresse, die im Datensatz ohnehin steht.
|
||||||
|
HOSTNAME_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$")
|
||||||
|
|
||||||
|
#: Zeilenende der Datei: CRLF. Der Viewer liest beides, aber die Datei landet
|
||||||
|
#: auf einem Windows-Arbeitsplatz -- und dort soll sie in jedem Editor
|
||||||
|
#: vernuenftig aussehen, wenn jemand nachsieht, was drinsteht.
|
||||||
|
ZEILE = "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Zugang:
|
||||||
|
"""Was die Detailseite ueber den Bildschirmzugriff eines Clients weiss."""
|
||||||
|
|
||||||
|
ziel: str
|
||||||
|
port: int
|
||||||
|
erreichbar: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adresse(self) -> str:
|
||||||
|
"""``vnc://host`` -- den Standardport schreibt niemand dazu."""
|
||||||
|
if self.port == STANDARD_PORT:
|
||||||
|
return f"vnc://{self.ziel}"
|
||||||
|
return f"vnc://{self.ziel}:{self.port}"
|
||||||
|
|
||||||
|
|
||||||
|
#: Aufloesungen, die wir schon einmal versucht haben: ``name -> (Zeit, Ergebnis)``.
|
||||||
|
#:
|
||||||
|
#: Nicht wegen der Geschwindigkeit -- ein arbeitender Resolver antwortet in
|
||||||
|
#: Bruchteilen einer Millisekunde --, sondern wegen des Gegenteils: faellt der
|
||||||
|
#: Nameserver aus, laeuft jede Abfrage in sein eigenes Zeitlimit. Ohne diesen
|
||||||
|
#: Puffer haette das der Seitenaufbau ausgebadet, und zwar bei jedem Aufruf.
|
||||||
|
_AUFLOESUNG: dict[str, tuple[float, bool]] = {}
|
||||||
|
|
||||||
|
#: Fuenf Minuten. Lang genug, damit ein kaputter Resolver nicht jede Seite
|
||||||
|
#: aufhaelt; kurz genug, dass ein neuer DNS-Eintrag nicht bis zum Neustart
|
||||||
|
#: wartet.
|
||||||
|
AUFLOESUNG_GILT = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def aufloesbar(name: str) -> bool:
|
||||||
|
"""Kennt der Resolver diesen Namen?"""
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
gemerkt = _AUFLOESUNG.get(name)
|
||||||
|
if gemerkt is not None and jetzt - gemerkt[0] < AUFLOESUNG_GILT:
|
||||||
|
return gemerkt[1]
|
||||||
|
try:
|
||||||
|
socket.getaddrinfo(name, None)
|
||||||
|
ergebnis = True
|
||||||
|
except OSError:
|
||||||
|
ergebnis = False
|
||||||
|
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def ziel(device: Mapping[str, Any]) -> str:
|
||||||
|
"""Der Name des Clients, sonst seine IP-Adresse.
|
||||||
|
|
||||||
|
Der Name ist die bessere Wahl, wo er gilt: er ueberlebt einen
|
||||||
|
Adresswechsel, und genau so stehen die gewachsenen Verbindungen im Viewer
|
||||||
|
(``Host=hap02``). Zwei Dinge muessen dafuer stimmen -- er muss als Hostname
|
||||||
|
**taugen** ("Kamera Eingang" tut es nicht) und er muss sich **aufloesen**
|
||||||
|
lassen. Sonst zeigte die Datei auf einen Namen, den der Arbeitsplatz nicht
|
||||||
|
kennt, und niemand saehe, woran es liegt; dann ist die Adresse richtig, die
|
||||||
|
ohnehin im Datensatz steht.
|
||||||
|
|
||||||
|
Loest der Name auf eine **andere** Adresse auf als die gespeicherte, gewinnt
|
||||||
|
trotzdem der Name: dann ist der Eintrag im Bestand veraltet, nicht das DNS.
|
||||||
|
"""
|
||||||
|
name = str(device.get("name") or "").strip()
|
||||||
|
if name and HOSTNAME_RE.match(name) and aufloesbar(name):
|
||||||
|
return name.lower()
|
||||||
|
return str(device.get("ip") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def erreichbar(host: str, port: int = STANDARD_PORT) -> bool:
|
||||||
|
"""Nimmt auf diesem Host ein VNC-Server Verbindungen an?"""
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
return tcp_reachable(host, port, timeout=ZEITLIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang:
|
||||||
|
"""Ziel und Erreichbarkeit in einem Stueck -- so nimmt es die Vorlage."""
|
||||||
|
host = ziel(device)
|
||||||
|
return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port) if pruefen else False)
|
||||||
|
|
||||||
|
|
||||||
|
def verbindungsdatei(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> str:
|
||||||
|
"""Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte.
|
||||||
|
|
||||||
|
``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst
|
||||||
|
von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host``
|
||||||
|
das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit
|
||||||
|
Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht.
|
||||||
|
"""
|
||||||
|
host = ziel(device)
|
||||||
|
if port != STANDARD_PORT:
|
||||||
|
host = f"{host}:{port}"
|
||||||
|
zeilen = [
|
||||||
|
"ConnMethod=tcp",
|
||||||
|
f"FriendlyName={str(device.get('name') or host).strip()}",
|
||||||
|
f"Host={host}",
|
||||||
|
]
|
||||||
|
return ZEILE.join(zeilen) + ZEILE
|
||||||
|
|
||||||
|
|
||||||
|
def dateiname(device: Mapping[str, Any]) -> str:
|
||||||
|
"""``HAP02.vnc`` -- aus dem Namen, auf Unbedenkliches beschraenkt.
|
||||||
|
|
||||||
|
Auch der Punkt faellt weg, nicht nur der Schraegstrich: ein Geraetename ist
|
||||||
|
Freitext, und ein Dateiname, in dem ".." vorkommen kann, ist eine Frage,
|
||||||
|
die man sich gar nicht erst stellen muss.
|
||||||
|
"""
|
||||||
|
name = re.sub(r"[^A-Za-z0-9_-]+", "-", str(device.get("name") or "client")).strip("-")
|
||||||
|
return f"{name or 'client'}.vnc"
|
||||||
|
|
||||||
|
|
||||||
|
def port_aus_einstellungen(extension: Any) -> int:
|
||||||
|
"""Der eingestellte Port -- oder 0, wenn der Zugang abgeschaltet ist.
|
||||||
|
|
||||||
|
Nimmt die Erweiterung statt der Verbindung, weil der Ueberwachungsprozess
|
||||||
|
ausserhalb eines Anfragekontextes laeuft und seine eigene Sitzung oeffnet.
|
||||||
|
"""
|
||||||
|
with extension.database.session() as conn:
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
return 0
|
||||||
|
return int(extension.settings.get(conn, "vnc_port") or STANDARD_PORT)
|
||||||
@@ -45,6 +45,27 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
),
|
),
|
||||||
|
# -- Bildschirmzugriff (VNC) -------------------------------------------- #
|
||||||
|
SettingSpec(
|
||||||
|
"vnc_enabled",
|
||||||
|
True,
|
||||||
|
"bool",
|
||||||
|
"Bildschirmzugriff (VNC) anbieten",
|
||||||
|
hint="Der Überwachungsdurchlauf fragt bei jedem erreichbaren Client kurz, ob "
|
||||||
|
"auf diesem Port ein VNC-Server antwortet; nur dort erscheint der Knopf. "
|
||||||
|
"Sehen darf ihn trotzdem nur, wer das Recht dafür hat.",
|
||||||
|
section="monitoring",
|
||||||
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"vnc_port",
|
||||||
|
5900,
|
||||||
|
"int",
|
||||||
|
"VNC-Port",
|
||||||
|
hint="5900 ist Anzeige :0 – die Vorgabe eines Raspberry Pi.",
|
||||||
|
section="monitoring",
|
||||||
|
minimum=1,
|
||||||
|
maximum=65535,
|
||||||
|
),
|
||||||
# -- DHCP --------------------------------------------------------------- #
|
# -- DHCP --------------------------------------------------------------- #
|
||||||
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
|
|||||||
@@ -84,21 +84,6 @@
|
|||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
||||||
Details
|
Details
|
||||||
</a>
|
</a>
|
||||||
{% if tile.restart_methods %}
|
|
||||||
{% set method = tile.restart_methods[0] %}
|
|
||||||
<form method="post" data-no-guard
|
|
||||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="method" value="{{ method.key }}">
|
|
||||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
|
||||||
data-busy-label="Startet ..."
|
|
||||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
|
||||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
|
||||||
data-confirm-style="primary">
|
|
||||||
{{ icon('power', size=14) }} {{ method.label }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
|
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
|
||||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
@@ -108,6 +93,42 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{# Die beiden Eingriffe stehen unten und immer an derselben
|
||||||
|
Stelle: links der Bildschirm, rechts der Neustart. Wer
|
||||||
|
eine Reihe Kacheln abarbeitet, trifft denselben Knopf,
|
||||||
|
ohne hinzusehen -- und verwechselt ihn nicht mit dem
|
||||||
|
anderen. #}
|
||||||
|
{% if tile.vnc or tile.restart_methods %}
|
||||||
|
<div class="tile__actions">
|
||||||
|
{% if tile.vnc %}
|
||||||
|
{# Unmittelbar die Verbindung, keine Datei: auf der
|
||||||
|
Uebersicht will man hin, nicht etwas herunterladen.
|
||||||
|
Die Verbindungsdatei fuer Viewer ohne registriertes
|
||||||
|
Schema steht auf der Detailseite. #}
|
||||||
|
<a class="btn btn--sm" href="{{ tile.vnc }}"
|
||||||
|
title="Bildschirm öffnen ({{ tile.vnc }})">
|
||||||
|
{{ icon('monitor', size=14) }} VNC
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="tile__spacer" aria-hidden="true"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if tile.restart_methods %}
|
||||||
|
{% set method = tile.restart_methods[0] %}
|
||||||
|
<form method="post" data-no-guard
|
||||||
|
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="method" value="{{ method.key }}">
|
||||||
|
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||||
|
data-busy-label="Startet ..."
|
||||||
|
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||||
|
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||||
|
data-confirm-style="primary">
|
||||||
|
{{ icon('power', size=14) }} {{ method.label }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -16,6 +16,15 @@
|
|||||||
{{ icon('terminal', size=15) }} Konsole
|
{{ icon('terminal', size=15) }} Konsole
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if vnc and vnc.erreichbar %}
|
||||||
|
{# Wie auf der Statusübersicht: der Knopf öffnet die Verbindung. Die
|
||||||
|
Verbindungsdatei steht eine Zeile tiefer in den Stammdaten -- für
|
||||||
|
Arbeitsplätze, an denen kein Viewer das Schema übernimmt. #}
|
||||||
|
<a class="btn btn--sm" href="{{ vnc.adresse }}"
|
||||||
|
title="Bildschirm öffnen ({{ vnc.adresse }})">
|
||||||
|
{{ icon('monitor', size=15) }} VNC
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if restart_methods %}
|
{% if restart_methods %}
|
||||||
{% for method in restart_methods %}
|
{% for method in restart_methods %}
|
||||||
<form method="post" style="display:inline"
|
<form method="post" style="display:inline"
|
||||||
@@ -225,6 +234,25 @@
|
|||||||
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
||||||
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
||||||
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
||||||
|
{% if vnc %}
|
||||||
|
<dt>Bildschirm (VNC)</dt>
|
||||||
|
<dd>
|
||||||
|
{% if vnc.erreichbar %}
|
||||||
|
<a class="mono" href="{{ vnc.adresse }}">{{ vnc.adresse }}</a>
|
||||||
|
<div class="fs-xs faint">
|
||||||
|
Öffnet den Viewer unmittelbar, sofern er <code>vnc://</code> übernimmt.
|
||||||
|
Sonst die
|
||||||
|
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||||
|
Verbindungsdatei</a> – sie enthält nur Ziel und Namen.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="faint">
|
||||||
|
kein VNC-Server auf {{ vnc.ziel }}:{{ vnc.port }}
|
||||||
|
{%- if device.state != 'online' %} (Client antwortet gerade nicht){% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
||||||
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||||
<dt>Geändert</dt>
|
<dt>Geändert</dt>
|
||||||
|
|||||||
@@ -46,6 +46,13 @@
|
|||||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||||
checked=values.dashboard_public,
|
checked=values.dashboard_public,
|
||||||
hint_text=specs['dashboard_public'].hint) }}
|
hint_text=specs['dashboard_public'].hint) }}
|
||||||
|
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
||||||
|
checked=values.vnc_enabled,
|
||||||
|
hint_text=specs['vnc_enabled'].hint) }}
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
||||||
|
hint_text=specs['vnc_port'].hint) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from types import FrameType
|
|||||||
from tesm_core.extension import core
|
from tesm_core.extension import core
|
||||||
|
|
||||||
from . import create_app
|
from . import create_app
|
||||||
from .services import inventory, monitor
|
from .services import inventory, monitor, vnc
|
||||||
|
|
||||||
logger = logging.getLogger("tesm.monitor")
|
logger = logging.getLogger("tesm.monitor")
|
||||||
|
|
||||||
@@ -82,6 +82,7 @@ class MonitorDaemon:
|
|||||||
keystore=self.extension.keystore,
|
keystore=self.extension.keystore,
|
||||||
license_allows_auto_restart=self._auto_restart_allowed(),
|
license_allows_auto_restart=self._auto_restart_allowed(),
|
||||||
failures_before_restart=self._failures_before_restart(),
|
failures_before_restart=self._failures_before_restart(),
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(self.extension),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.8.3"
|
version = "2.8.4"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -1208,6 +1208,12 @@
|
|||||||
--tile-accent: var(--text-faint);
|
--tile-accent: var(--text-faint);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* Spalte, damit die Fusszeile unten haengen kann (siehe .tile__actions).
|
||||||
|
Die Kacheln eines Rasters sind gleich hoch, ihr Inhalt aber nicht: mal
|
||||||
|
fehlt der Switch, mal der Standort. Ohne diesen Halt stuenden die Knoepfe
|
||||||
|
in jeder Kachel auf einer anderen Hoehe -- und das Auge sucht sie. */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: var(--sp-4);
|
padding: var(--sp-4);
|
||||||
padding-left: calc(var(--sp-4) + 3px);
|
padding-left: calc(var(--sp-4) + 3px);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -1269,6 +1275,24 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fusszeile der Kachel: links der Bildschirm, rechts der Neustart.
|
||||||
|
``margin-top: auto`` schiebt sie an den unteren Rand -- unabhaengig
|
||||||
|
davon, wie viel darueber steht. Die beiden Eingriffe stehen damit in
|
||||||
|
jeder Kachel an derselben Stelle, und der eine liegt nicht dort, wo eben
|
||||||
|
noch der andere war. */
|
||||||
|
.tile__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
.tile__actions > :last-child { margin-left: auto; }
|
||||||
|
/* Steht nur ein Knopf da, bleibt er links -- sonst spraenge der Neustart bei
|
||||||
|
einem Geraet ohne Bildschirmzugang an die Stelle, an der nebenan VNC
|
||||||
|
steht. Das Fuellstueck haelt den Platz. */
|
||||||
|
.tile__spacer { flex: 1 1 auto; }
|
||||||
|
|
||||||
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
||||||
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
||||||
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
||||||
|
|||||||
Reference in new issue
Block a user