Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c40ccfbd | ||
|
|
0ac651a1b9 | ||
|
|
4275c84a6c |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.9"
|
||||
version = "2.8.12"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,6 +24,8 @@ 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
|
||||
|
||||
@@ -64,16 +66,51 @@ class Zugang:
|
||||
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 aufloest: er ueberlebt einen
|
||||
Adresswechsel. Taugt er nicht als Hostname -- weil er ein Leerzeichen
|
||||
enthaelt oder ein Umlaut darin steht --, waere ein Link darauf sicher
|
||||
falsch; dann ist die Adresse richtig.
|
||||
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):
|
||||
if name and HOSTNAME_RE.match(name) and aufloesbar(name):
|
||||
return name.lower()
|
||||
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) }}">
|
||||
Details
|
||||
</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' %}
|
||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||
data-no-guard>
|
||||
@@ -114,6 +93,42 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if vnc and vnc.erreichbar %}
|
||||
<a class="btn btn--sm" href="{{ url_for('devices.vnc_connection', device_id=device.id) }}"
|
||||
title="Verbindungsdatei für {{ vnc.ziel }}:{{ vnc.port }} – öffnet den VNC-Viewer">
|
||||
{# 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 %}
|
||||
@@ -237,9 +240,10 @@
|
||||
{% 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 herunterladen</a> – enthält nur Ziel und Namen.
|
||||
Der Link daneben braucht einen Viewer, der <code>vnc://</code> übernimmt.
|
||||
Verbindungsdatei</a> – sie enthält nur Ziel und Namen.
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint">
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user