Compare commits

...
3 Commits
11 changed files with 174 additions and 47 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.8.9" 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 = [
+27 -6
View File
@@ -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,12 +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,
), ),
# Aus dem letzten Durchlauf, nicht frisch gefragt: die # Die fertige Adresse, nicht nur ein Schalter: der Knopf
# Uebersicht zeigt viele Kacheln und laedt sich selbst # auf der Kachel soll die Verbindung oeffnen und nicht
# nach. Ein Verbindungsversuch je Kachel und Aufbau waere # erst eine Datei ablegen, die jemand anklicken muss.
# genau die Art Hintergrundlast, die niemand bestellt hat. #
"vnc": bool(row["vnc_open"]) # Beides -- ob dort ein Server antwortet und wohin gefragt
and current_user.has("device_vnc.execute"), # 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)
+1 -1
View File
@@ -392,7 +392,7 @@ def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
zugang = vnc.zugang(device, port=port, pruefen=online) zugang = vnc.zugang(device, port=port, pruefen=online)
if online: if online:
with transaction(): with transaction():
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar) monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
return zugang return zugang
+13
View File
@@ -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] 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.
@@ -546,4 +558,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
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=10, name="vnc-erreichbarkeit", sql=V10),
Migration(version=11, name="vnc-ziel", sql=V11),
) )
+2 -1
View File
@@ -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, 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 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
+19 -7
View File
@@ -507,11 +507,20 @@ class SweepReport:
duration_ms: int = 0 duration_ms: int = 0
def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool) -> None: def merke_vnc(
"""Haelt fest, ob dort gerade ein VNC-Server antwortet.""" 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( conn.execute(
"UPDATE device_status SET vnc_open=?, vnc_checked_at=? WHERE device_id=?", "UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? "
(1 if offen else 0, _now(), device_id), "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) report.duration_ms = int((time.monotonic() - started) * 1000)
return report 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"])) ergebnis = ping(str(device["ip"]))
if not vnc_port or not ergebnis.ok: if not vnc_port or not ergebnis.ok:
# Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt # Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt
# 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.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: with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
results = list(pool.map(pruefen, devices)) results = list(pool.map(pruefen, devices))
@@ -566,7 +578,7 @@ def run_sweep(
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: 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: if result.ok:
report.online += 1 report.online += 1
else: else:
+42 -5
View File
@@ -24,6 +24,8 @@ niemand muss ihn pflegen, und er zeigt nie ins Leere.
from __future__ import annotations from __future__ import annotations
import re import re
import socket
import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Mapping from typing import Any, Mapping
@@ -64,16 +66,51 @@ class Zugang:
return f"vnc://{self.ziel}:{self.port}" 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: def ziel(device: Mapping[str, Any]) -> str:
"""Der Name des Clients, sonst seine IP-Adresse. """Der Name des Clients, sonst seine IP-Adresse.
Der Name ist die bessere Wahl, wo er aufloest: er ueberlebt einen Der Name ist die bessere Wahl, wo er gilt: er ueberlebt einen
Adresswechsel. Taugt er nicht als Hostname -- weil er ein Leerzeichen Adresswechsel, und genau so stehen die gewachsenen Verbindungen im Viewer
enthaelt oder ein Umlaut darin steht --, waere ein Link darauf sicher (``Host=hap02``). Zwei Dinge muessen dafuer stimmen -- er muss als Hostname
falsch; dann ist die Adresse richtig. **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() name = str(device.get("name") or "").strip()
if name and HOSTNAME_RE.match(name): if name and HOSTNAME_RE.match(name) and aufloesbar(name):
return name.lower() return name.lower()
return str(device.get("ip") or "").strip() return str(device.get("ip") or "").strip()
@@ -84,27 +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 tile.vnc %}
<a class="btn btn--sm" title="Verbindungsdatei für den Bildschirm"
href="{{ url_for('devices.vnc_connection', device_id=tile.id) }}">
{{ icon('monitor', size=14) }} VNC
</a>
{% 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>
@@ -114,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 %}
@@ -17,8 +17,11 @@
</a> </a>
{% endif %} {% endif %}
{% if vnc and vnc.erreichbar %} {% if vnc and vnc.erreichbar %}
<a class="btn btn--sm" href="{{ url_for('devices.vnc_connection', device_id=device.id) }}" {# Wie auf der Statusübersicht: der Knopf öffnet die Verbindung. Die
title="Verbindungsdatei für {{ vnc.ziel }}:{{ vnc.port }} – öffnet den VNC-Viewer"> 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 {{ icon('monitor', size=15) }} VNC
</a> </a>
{% endif %} {% endif %}
@@ -237,9 +240,10 @@
{% if vnc.erreichbar %} {% if vnc.erreichbar %}
<a class="mono" href="{{ vnc.adresse }}">{{ vnc.adresse }}</a> <a class="mono" href="{{ vnc.adresse }}">{{ vnc.adresse }}</a>
<div class="fs-xs faint"> <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) }}"> <a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
Verbindungsdatei herunterladen</a> – enthält nur Ziel und Namen. Verbindungsdatei</a> – sie enthält nur Ziel und Namen.
Der Link daneben braucht einen Viewer, der <code>vnc://</code> übernimmt.
</div> </div>
{% else %} {% else %}
<span class="faint"> <span class="faint">
+1 -1
View File
@@ -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