DHCP: Subnetze deaktivierbar + 47 verifizierte Standard-Optionen

1. Subnetze koennen einzeln deaktiviert werden (enabled-Spalte), ohne sie
   loeschen zu muessen -- werden dann bei "In Datei schreiben" und
   "Aktivieren & (neu) starten" komplett uebersprungen. Bewusst nicht als
   "ungueltig" gewertet (kein Fehler/keine Warnung), da eine deaktivierte
   Zeile absichtlich kein echtes Netz braucht. Toggle-Button in der
   Subnetz-Tabelle, deaktivierte Zeilen abgeblendet mit "Deaktiviert"-Pill.

2. 47 kuratierte Standard-DHCP-Optionen vorbefuellt (analog den
   "Predefined Options" eines Windows-DHCP-Servers) -- Router/DNS-Server/
   Domain-Name/Lease-Zeiten ausgenommen, dafuer gibt es bereits eigene
   Felder. Alle Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert:
   jede der 49 Kandidaten-Optionen einzeln per kea-dhcp4 -t getestet, 47
   akzeptiert (2 falsch geratene Namen verworfen: nis-domain-name statt
   nis-domain, nisplus-domain statt nisplus-domain-name).

   Wichtig fuer Kea-Kompatibilitaet: Standard-Optionen bekommen KEIN
   eigenes option-def in der generierten Konfiguration (neue is_standard-
   Spalte in dhcp_option_defs) -- Kea kennt sie bereits nativ, eine
   Neudefinition haette die eingebaute dupliziert. Nur echte eigene/
   herstellerspezifische Options bekommen weiterhin ein option-def.
   Live verifiziert: option-def bleibt bei gesetztem Standard-Options-Wert
   leer, option-data enthaelt den Wert korrekt, kea-dhcp4 -t besteht,
   echter Dienstneustart mit der Config erfolgreich.

   Einmalige Migration (Guard wie bei den anderen Schema-Migrationen) --
   ein vom Admin bewusst geloeschter Standard-Eintrag wird nach einem
   Neustart nicht stillschweigend wieder angelegt.

Ausserdem in dieser Session zuvor gefixt (siehe vorherige Commits): der
falsche Kea-Schluessel lease-file->name, der jeden Dienststart blockierte,
sowie der 'Jetzt pruefen'-Button, der den Topbar-Countdown nur auf dem
Dashboard aktualisierte.
This commit is contained in:
2026-08-11 15:06:55 +02:00
parent 182457629d
commit 9885a579ea
3 changed files with 175 additions and 21 deletions
+26 -9
View File
@@ -265,15 +265,32 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
abzubrechen) und bei „Aktivieren & (neu) starten“ (verweigert den Start, abzubrechen) und bei „Aktivieren & (neu) starten“ (verweigert den Start,
falls kein einziges gültiges Subnetz mehr existiert). Live auf einer falls kein einziges gültiges Subnetz mehr existiert). Live auf einer
echten Fehlkonfiguration reproduziert und verifiziert. echten Fehlkonfiguration reproduziert und verifiziert.
- **Eigene Options** (`dhcp_option_defs`/`dhcp_option_values`, analog den - **Subnetze einzeln deaktivierbar**: ein Subnetz lässt sich ohne Löschen
„Vordefinierten Optionen und Werten“ eines Windows-DHCP-Servers): Code, pausieren (`enabled`-Flag) — wird dann bei „In Datei schreiben“ und
Name, Kea-Datentyp und Beschreibung frei definierbar (z.B. herstellerspezifische „Aktivieren & (neu) starten“ komplett ignoriert (kein Fehler, bewusst
Options wie eine Terminal-Boot-URL). Jede Option hat einen **globalen** kein "ungültig", da das Fehlen des Netzes hier Absicht ist), statt es
Wert sowie optional einen **Wert pro Client** — ein Client-Override jedes Mal neu anlegen zu müssen.
überschreibt den globalen Wert ausschließlich für dieses eine Gerät - **DHCP-Options** (`dhcp_option_defs`/`dhcp_option_values`): eine kuratierte
(technisch: globale Werte landen im Top-Level `option-data`, Overrides im Auswahl an **47 Standard-Optionen** (analog den „Vordefinierten Optionen“
`option-data` der jeweiligen Kea-Reservierung, was Kea automatisch pro eines Windows-DHCP-Servers — z.B. NTP-/WINS-/TFTP-/SMTP-Server, Boot-Datei,
Client Vorrang geben lässt). Domain-Search, IP-Forwarding) ist automatisch vorbefüllt; Router/DNS-Server/
Domain-Name/Lease-Zeiten sind bewusst ausgenommen, da dafür bereits eigene,
dedizierte Felder existieren (Subnetze bzw. globale Einstellungen oben).
Alle 47 Namen sind live gegen eine echte Kea-2.4.1-Instanz verifiziert
(`kea-dhcp4 -t`, jede Option einzeln getestet). Standard-Optionen bekommen
bewusst **kein eigenes `option-def`** in der generierten Konfiguration —
Kea kennt sie bereits nativ, eine Neudefinition würde die eingebaute
duplizieren; nur zusätzlich angelegte, wirklich eigene/herstellerspezifische
Options (z.B. eine Terminal-Boot-URL) bekommen eines. Beide Arten
funktionieren im UI identisch: Code, Name, Kea-Datentyp und Beschreibung
frei definierbar, jede Option hat einen **globalen** Wert sowie optional
einen **Wert pro Client** — ein Client-Override überschreibt den globalen
Wert ausschließlich für dieses eine Gerät (technisch: globale Werte landen
im Top-Level `option-data`, Overrides im `option-data` der jeweiligen
Kea-Reservierung, was Kea automatisch pro Client Vorrang geben lässt).
Ein versehentlich vom Admin gelöschter Standard-Eintrag wird nach einem
Neustart nicht automatisch wieder angelegt (einmalige Migration, kein
Zurückschreiben bei jedem Start).
- **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP wird eine - **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP wird eine
Kea-Reservierung im **passenden Subnetz** erzeugt (die IP entscheidet, zu Kea-Reservierung im **passenden Subnetz** erzeugt (die IP entscheidet, zu
welchem der ggf. mehreren konfigurierten Subnetze ein Gerät gehört); der welchem der ggf. mehreren konfigurierten Subnetze ein Gerät gehört); der
+122 -7
View File
@@ -491,6 +491,64 @@ def get_db_connection():
return conn return conn
# Kuratierte Standard-DHCPv4-Optionen (analog den "Predefined Options" eines
# Windows-DHCP-Servers) — vorbefüllt in _ensure_schema(), siehe dort für die
# Begründung. Muss vor dem _ensure_schema()-Aufruf am Ende der Datei stehen.
# (code, Kea-Name, Anzeige-Typ, Beschreibung). Router (3)/DNS-Server (6)/
# Domain-Name (15)/Lease-Zeiten (51/58/59) sind bereits eigene, dedizierte
# Felder und deshalb bewusst nicht enthalten. Alle 47 Namen live gegen eine
# echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option einzeln).
DHCP_STANDARD_OPTIONS = [
(4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"),
(7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"),
(8, "cookie-servers", "ipv4-address", "Cookie-Server"),
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
(10, "impress-servers", "ipv4-address", "Impress-Server"),
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
(16, "swap-server", "ipv4-address", "Swap-Server"),
(17, "root-path", "string", "Root-Pfad (NFS)"),
(18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"),
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
(27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"),
(28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"),
(29, "perform-mask-discovery", "boolean", "Subnetzmasken-Discovery"),
(30, "mask-supplier", "boolean", "Subnetzmasken-Anbieter"),
(31, "router-discovery", "boolean", "Router Discovery (RFC 1256)"),
(32, "router-solicitation-address", "ipv4-address", "Router-Solicitation-Adresse"),
(33, "static-routes", "string", "Statische Routen (Ziel,Gateway,...)"),
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
(37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
(40, "nis-domain", "string", "NIS-Domain"),
(41, "nis-servers", "ipv4-address", "NIS-Server"),
(42, "ntp-servers", "ipv4-address", "NTP-Server"),
(44, "netbios-name-servers", "ipv4-address", "WINS/NBNS-Server"),
(45, "netbios-dd-server", "ipv4-address", "NetBIOS Datagram Distribution Server"),
(46, "netbios-node-type", "uint8", "WINS/NBT-Knotentyp"),
(47, "netbios-scope", "string", "NetBIOS-Scope-ID"),
(48, "font-servers", "ipv4-address", "X-Window-Font-Server"),
(49, "x-display-manager", "ipv4-address", "X-Window Display Manager"),
(64, "nisplus-domain-name", "string", "NIS+-Domain"),
(65, "nisplus-servers", "ipv4-address", "NIS+-Server"),
(66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"),
(67, "boot-file-name", "string", "Bootdatei-Name"),
(68, "mobile-ip-home-agent", "ipv4-address", "Mobile-IP-Home-Agent"),
(69, "smtp-server", "ipv4-address", "SMTP-Server"),
(70, "pop-server", "ipv4-address", "POP3-Server"),
(71, "nntp-server", "ipv4-address", "NNTP-Server"),
(72, "www-server", "ipv4-address", "WWW-Server"),
(73, "finger-server", "ipv4-address", "Finger-Server"),
(74, "irc-server", "ipv4-address", "IRC-Server"),
(75, "streettalk-server", "ipv4-address", "StreetTalk-Server"),
(76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"),
(119, "domain-search", "string", "DNS-Suchliste (Domain Search)"),
]
def _ensure_schema(): def _ensure_schema():
"""Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer """Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer
älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen. älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen.
@@ -590,9 +648,15 @@ def _ensure_schema():
code INTEGER NOT NULL UNIQUE, code INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL, type TEXT NOT NULL,
description TEXT description TEXT,
is_standard INTEGER NOT NULL DEFAULT 0
) )
""") """)
# Migration: "is_standard"-Spalte nachrüsten, falls die Tabelle noch aus
# einer Version ohne vorbefüllte Standard-Optionen stammt.
option_def_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_option_defs)").fetchall()}
if "is_standard" not in option_def_cols:
conn.execute("ALTER TABLE dhcp_option_defs ADD COLUMN is_standard INTEGER NOT NULL DEFAULT 0")
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_option_values ( CREATE TABLE IF NOT EXISTS dhcp_option_values (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -603,6 +667,26 @@ def _ensure_schema():
UNIQUE (option_def_id, device_mac) UNIQUE (option_def_id, device_mac)
) )
""") """)
# Einmalig eine kuratierte Auswahl an Standard-DHCP-Optionen vorbefüllen
# (analog den "Predefined Options" eines Windows-DHCP-Servers) — Router/
# DNS-Server/Domain-Name/Lease-Zeiten sind bereits eigene, dedizierte
# Felder (siehe dhcp_subnets/DHCP_DEFAULTS) und deshalb hier bewusst
# ausgenommen. Alle Namen live gegen eine echte Kea-2.4.1-Instanz
# verifiziert (kea-dhcp4 -t) — Kea kennt diese Standard-Optionen bereits
# nativ, sie bekommen deshalb (anders als eigene Options) KEIN eigenes
# option-def in der generierten Konfiguration (siehe _render_kea_config),
# sonst würde die eingebaute Definition dupliziert. Per Guard nur
# einmalig, damit ein bewusst vom Admin gelöschter Eintrag nach einem
# Neustart nicht stillschweigend wieder auftaucht.
_migration_key_std_opts = "_seeded_dhcp_standard_options_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts,)).fetchone():
for code, name, opt_type, description in DHCP_STANDARD_OPTIONS:
conn.execute(
"INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) "
"VALUES (?, ?, ?, ?, 1)",
(code, name, opt_type, description),
)
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts,))
# DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen # DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen
# Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz # Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz
@@ -618,9 +702,15 @@ def _ensure_schema():
range_end TEXT NOT NULL, range_end TEXT NOT NULL,
gateway TEXT, gateway TEXT,
dns TEXT, dns TEXT,
sort_order INTEGER NOT NULL DEFAULT 0 sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1
) )
""") """)
# Migration: "enabled"-Spalte nachrüsten, falls die Tabelle noch aus
# einer Version ohne Deaktivieren-Funktion stammt.
dhcp_subnet_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_subnets)").fetchall()}
if "enabled" not in dhcp_subnet_cols:
conn.execute("ALTER TABLE dhcp_subnets ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1")
# Migration: bisherige globale Einzel-Range (dhcp_interface/_range_*/ # Migration: bisherige globale Einzel-Range (dhcp_interface/_range_*/
# _gateway/_dns in der settings-Tabelle) einmalig in eine erste Zeile # _gateway/_dns in der settings-Tabelle) einmalig in eine erste Zeile
# der neuen Tabelle überführen, statt eine bereits funktionierende # der neuen Tabelle überführen, statt eine bereits funktionierende
@@ -1912,11 +2002,16 @@ def _dhcp_valid_subnets(subnets):
"""Nimmt die rohen dhcp_subnets-Zeilen und validiert JEDE einzeln gegen """Nimmt die rohen dhcp_subnets-Zeilen und validiert JEDE einzeln gegen
ihr tatsächliches Netz (siehe _dhcp_subnet_matches — es muss eine ihr tatsächliches Netz (siehe _dhcp_subnet_matches — es muss eine
physische IP im gewünschten Bereich vorhanden sein, sonst kann Kea physische IP im gewünschten Bereich vorhanden sein, sonst kann Kea
damit nichts anfangen). Gibt (valid, invalid) zurück: valid ist um damit nichts anfangen). Deaktivierte Subnetze (enabled=0) werden dabei
"net_info" angereichert (für Reservierungs-Zuordnung und Kea-Config), komplett übersprungen — bewusst nicht als "invalid" gewertet, da das
invalid enthält (Zeile, Fehlertext) für die Anzeige/Warnungen.""" Fehlen eines echten Netzes hier kein Fehler ist, sondern Absicht.
Gibt (valid, invalid) zurück: valid ist um "net_info" angereichert (für
Reservierungs-Zuordnung und Kea-Config), invalid enthält (Zeile,
Fehlertext) für die Anzeige/Warnungen."""
valid, invalid = [], [] valid, invalid = [], []
for s in subnets: for s in subnets:
if not s["enabled"]:
continue
net_info, error = _dhcp_subnet_matches(s) net_info, error = _dhcp_subnet_matches(s)
if error: if error:
invalid.append((s, error)) invalid.append((s, error))
@@ -1975,7 +2070,9 @@ def _dhcp_reservation_candidates(conn, valid_subnets):
def _dhcp_option_defs(conn): def _dhcp_option_defs(conn):
return conn.execute("SELECT id, code, name, type, description FROM dhcp_option_defs ORDER BY code ASC").fetchall() return conn.execute(
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs ORDER BY code ASC"
).fetchall()
def _dhcp_option_values(conn): def _dhcp_option_values(conn):
@@ -2045,9 +2142,13 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs,
Reservierung — Kea überschreibt damit automatisch gleichnamige Reservierung — Kea überschreibt damit automatisch gleichnamige
globale/Subnet-Options für genau diesen Client, ohne dass wir das globale/Subnet-Options für genau diesen Client, ohne dass wir das
selbst zusammenrechnen müssen.""" selbst zusammenrechnen müssen."""
# Standard-Optionen (is_standard) bekommen KEIN eigenes option-def — Kea
# kennt sie bereits nativ, eine Neudefinition würde die eingebaute
# duplizieren (siehe DHCP_STANDARD_OPTIONS). Nur eigene Options brauchen
# ein option-def, damit Kea Name/Typ/Code überhaupt kennt.
option_def_list = [ option_def_list = [
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"} {"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
for d in option_defs for d in option_defs if not d["is_standard"]
] ]
global_option_data = [] global_option_data = []
@@ -2201,6 +2302,20 @@ def settings_dhcp():
log_action("settings.dhcp_subnet_delete", subnet_id) log_action("settings.dhcp_subnet_delete", subnet_id)
flash("Subnetz gelöscht.", "success") flash("Subnetz gelöscht.", "success")
elif "toggle_dhcp_subnet" in request.form:
subnet_id = request.form.get("toggle_dhcp_subnet")
row = conn.execute("SELECT enabled, interface FROM dhcp_subnets WHERE id=?", (subnet_id,)).fetchone()
if row:
new_state = 0 if row["enabled"] else 1
conn.execute("UPDATE dhcp_subnets SET enabled=? WHERE id=?", (new_state, subnet_id))
conn.commit()
log_action("settings.dhcp_subnet_toggle", row["interface"], "aktiviert" if new_state else "deaktiviert")
flash(
"Subnetz aktiviert." if new_state else
"Subnetz deaktiviert — wird beim nächsten Schreiben/Starten nicht mehr berücksichtigt.",
"success",
)
elif "add_dhcp_option" in request.form: elif "add_dhcp_option" in request.form:
code_raw = request.form.get("code", "").strip() code_raw = request.form.get("code", "").strip()
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
+27 -5
View File
@@ -113,13 +113,15 @@
<tbody> <tbody>
{% for s in subnets %} {% for s in subnets %}
{% set st = subnet_status.get(s.id) %} {% set st = subnet_status.get(s.id) %}
<tr> <tr {% if not s.enabled %}style="opacity:.55;"{% endif %}>
<td class="mono">{{ s.interface }}</td> <td class="mono">{{ s.interface }}</td>
<td class="mono">{{ s.range_start }} {{ s.range_end }}</td> <td class="mono">{{ s.range_start }} {{ s.range_end }}</td>
<td class="mono text-faint">{{ s.gateway or '(automatisch)' }}</td> <td class="mono text-faint">{{ s.gateway or '(automatisch)' }}</td>
<td class="mono text-faint">{{ s.dns or '—' }}</td> <td class="mono text-faint">{{ s.dns or '—' }}</td>
<td> <td>
{% if st and st.ok %} {% if not s.enabled %}
<span class="pill disabled">Deaktiviert</span>
{% elif st and st.ok %}
<span class="pill online">Erkannt</span> <span class="pill online">Erkannt</span>
<span class="mono text-faint" style="font-size:11.5px;">{{ st.network }}</span> <span class="mono text-faint" style="font-size:11.5px;">{{ st.network }}</span>
{% else %} {% else %}
@@ -130,6 +132,16 @@
<td> <td>
{% if can_edit %} {% if can_edit %}
<div class="flex gap-2"> <div class="flex gap-2">
<form method="post">
<input type="hidden" name="toggle_dhcp_subnet" value="{{ s.id }}">
<button type="submit" class="icon-btn" title="{{ 'Deaktivieren' if s.enabled else 'Aktivieren' }}">
{% if s.enabled %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M4.9 4.9l14.2 14.2"/></svg>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
{% endif %}
</button>
</form>
<button type="button" class="icon-btn" title="Bearbeiten" data-open-modal="editSubnetModal{{ loop.index }}"> <button type="button" class="icon-btn" title="Bearbeiten" data-open-modal="editSubnetModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4z"/></svg>
</button> </button>
@@ -156,8 +168,11 @@
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Eigene DHCP-Options</h2> <h2 style="font-size:16px;">DHCP-Options</h2>
<div class="hint">Herstellerspezifische Options, global oder pro Client überschreibbar.</div> <div class="hint">
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal"> <button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
@@ -171,13 +186,20 @@
<div class="table-wrap"> <div class="table-wrap">
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table">
<thead><tr><th>Code</th><th>Name</th><th>Typ</th><th>Beschreibung</th><th>Globaler Wert</th><th style="width:1%;">Aktionen</th></tr></thead> <thead><tr><th>Code</th><th>Name</th><th>Typ</th><th>Herkunft</th><th>Beschreibung</th><th>Globaler Wert</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody> <tbody>
{% for d in option_defs %} {% for d in option_defs %}
<tr> <tr>
<td class="mono">{{ d.code }}</td> <td class="mono">{{ d.code }}</td>
<td class="mono">{{ d.name }}</td> <td class="mono">{{ d.name }}</td>
<td>{{ d.type }}</td> <td>{{ d.type }}</td>
<td>
{% if d.is_standard %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);">Standard</span>
{% else %}
<span class="pill user">Eigen</span>
{% endif %}
</td>
<td class="text-faint" style="font-size:12px;">{{ d.description or '—' }}</td> <td class="text-faint" style="font-size:12px;">{{ d.description or '—' }}</td>
<td> <td>
{% if can_edit %} {% if can_edit %}