diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 540910b..8cb6553 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.10" +version = "2.8.11" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/dashboard.py b/apps/tesm/src/tesm/blueprints/dashboard.py index 28efccd..b4304d1 100644 --- a/apps/tesm/src/tesm/blueprints/dashboard.py +++ b/apps/tesm/src/tesm/blueprints/dashboard.py @@ -39,11 +39,21 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: from ..services import inventory, restart + from ..services import vnc as vnc_dienst + conn = db() counts = monitor.dashboard_counts(conn) rows = inventory.list_devices(conn, only_active=not authenticated) 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]] = [] for row in rows: entry: dict[str, Any] = { @@ -74,12 +84,23 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: maintenance_active=_maintenance_active(), darf=current_user.has, ), - # Aus dem letzten Durchlauf, nicht frisch gefragt: die - # Uebersicht zeigt viele Kacheln und laedt sich selbst - # nach. Ein Verbindungsversuch je Kachel und Aufbau waere - # genau die Art Hintergrundlast, die niemand bestellt hat. - "vnc": bool(row["vnc_open"]) - and current_user.has("device_vnc.execute"), + # 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) diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py index 8b0f692..e7f7bcd 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -392,7 +392,7 @@ def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None: zugang = vnc.zugang(device, port=port, pruefen=online) if online: with transaction(): - monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar) + monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel) return zugang diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py index b2c315a..357dd53 100644 --- a/apps/tesm/src/tesm/schema.py +++ b/apps/tesm/src/tesm/schema.py @@ -503,6 +503,18 @@ 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] """Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen. @@ -546,4 +558,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe), 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), ) diff --git a/apps/tesm/src/tesm/services/inventory.py b/apps/tesm/src/tesm/services/inventory.py index 1311df1..bb4141d 100644 --- a/apps/tesm/src/tesm/services/inventory.py +++ b/apps/tesm/src/tesm/services/inventory.py @@ -542,7 +542,8 @@ def list_devices( 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, st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count, - st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at + 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 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 diff --git a/apps/tesm/src/tesm/services/monitor.py b/apps/tesm/src/tesm/services/monitor.py index 1727bda..140a6f1 100644 --- a/apps/tesm/src/tesm/services/monitor.py +++ b/apps/tesm/src/tesm/services/monitor.py @@ -507,11 +507,20 @@ class SweepReport: duration_ms: int = 0 -def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool) -> None: - """Haelt fest, ob dort gerade ein VNC-Server antwortet.""" +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=? WHERE device_id=?", - (1 if offen else 0, _now(), device_id), + "UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? " + "WHERE device_id=?", + (1 if offen else 0, _now(), ziel, device_id), ) @@ -547,14 +556,17 @@ def run_sweep( report.duration_ms = int((time.monotonic() - started) * 1000) return report - def pruefen(device: dict[str, Any]) -> tuple[dict[str, Any], PingResult, bool | None]: + 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 - return device, ergebnis, vnc_dienst.erreichbar(vnc_dienst.ziel(device), vnc_port) + 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: results = list(pool.map(pruefen, devices)) @@ -566,7 +578,7 @@ def run_sweep( report.checked += 1 previous, current = record_status(conn, int(device["id"]), result) if vnc_offen is not None: - merke_vnc(conn, int(device["id"]), vnc_offen) + merke_vnc(conn, int(device["id"]), vnc_offen[0], vnc_offen[1]) if result.ok: report.online += 1 else: diff --git a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html index 5382c02..c5d53de 100644 --- a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html +++ b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html @@ -84,27 +84,6 @@ Details - {% if tile.restart_methods %} - {% set method = tile.restart_methods[0] %} -
- - - -
- {% endif %} - {% if tile.vnc %} - - {{ icon('monitor', size=14) }} VNC - - {% endif %} {% if current_user.has('devices.write') and tile.state == 'disabled' %}
@@ -114,6 +93,42 @@
{% endif %} + {# 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 %} +
+ {% 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. #} + + {{ icon('monitor', size=14) }} VNC + + {% else %} + + {% endif %} + {% if tile.restart_methods %} + {% set method = tile.restart_methods[0] %} +
+ + + +
+ {% endif %} +
+ {% endif %} {% endif %} {% endfor %} diff --git a/apps/tesm/src/tesm/templates/tesm/device_detail.html b/apps/tesm/src/tesm/templates/tesm/device_detail.html index c287126..140f352 100644 --- a/apps/tesm/src/tesm/templates/tesm/device_detail.html +++ b/apps/tesm/src/tesm/templates/tesm/device_detail.html @@ -17,8 +17,11 @@ {% 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. #} + {{ icon('monitor', size=15) }} VNC {% endif %} @@ -237,9 +240,10 @@ {% if vnc.erreichbar %} {{ vnc.adresse }}
+ Öffnet den Viewer unmittelbar, sofern er vnc:// übernimmt. + Sonst die - Verbindungsdatei herunterladen – enthält nur Ziel und Namen. - Der Link daneben braucht einen Viewer, der vnc:// übernimmt. + Verbindungsdatei – sie enthält nur Ziel und Namen.
{% else %} diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css index 20168bd..5b314a4 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css @@ -1208,6 +1208,12 @@ --tile-accent: var(--text-faint); position: relative; 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-left: calc(var(--sp-4) + 3px); border: 1px solid var(--border-subtle); @@ -1269,6 +1275,24 @@ 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 absolut positioniert hinter der Karte -- auf einem flachen Fenster schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein