novy prehled prirazovani IP apod.

This commit is contained in:
JiriUhlir
2026-07-30 07:13:12 +02:00
parent 6beca6f79a
commit f67a3fc993
8 changed files with 1230 additions and 16 deletions
+13 -9
View File
@@ -414,11 +414,13 @@ def get_app_ip_access_rules(app_id: str):
rows = con.execute(
"""
SELECT id, app_id, ip_cidr, methods, description,
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
FROM app_ip_access_rules
WHERE app_id = ?
ORDER BY id
SELECT r.id, r.app_id, r.ip_cidr, r.methods, r.description,
COALESCE(r.is_enabled, 1) AS is_enabled, r.created_at, r.updated_at,
r.address_id, a.label AS address_label
FROM app_ip_access_rules r
LEFT JOIN ip_addresses a ON a.id = r.address_id
WHERE r.app_id = ?
ORDER BY r.id
""",
(app_id,),
).fetchall()
@@ -433,10 +435,12 @@ def get_app_ip_access_rule(rule_id: int, app_id: str):
row = con.execute(
"""
SELECT id, app_id, ip_cidr, methods, description,
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
FROM app_ip_access_rules
WHERE id = ? AND app_id = ?
SELECT r.id, r.app_id, r.ip_cidr, r.methods, r.description,
COALESCE(r.is_enabled, 1) AS is_enabled, r.created_at, r.updated_at,
r.address_id, a.label AS address_label
FROM app_ip_access_rules r
LEFT JOIN ip_addresses a ON a.id = r.address_id
WHERE r.id = ? AND r.app_id = ?
""",
(rule_id, app_id),
).fetchone()
+368
View File
@@ -0,0 +1,368 @@
"""Katalog pojmenovaných IP adres a jejich přiřazování ke službám.
Zdrojem pravdy pro vynucování zůstává tabulka ``app_ip_access_rules`` (jeden řádek = jedna
IP/CIDR + metody pro jednu službu) — tu čte generátor Caddyfile. Tabulka ``ip_addresses`` je
nad ní jen pojmenovaný katalog: adresu založím jednou a pak ji přiřazuji do N služeb.
Přiřazený řádek má ``address_id`` na katalog a ``ip_cidr`` zkopírovanou z katalogu, ručně
zadaná pravidla mají ``address_id = NULL`` a fungují dál bez změny.
"""
from app.db.database import get_connection
from app.db.migrations import run_migrations
def get_ip_addresses() -> list[dict]:
"""Katalog adres včetně počtu služeb, do kterých je adresa přiřazená."""
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT
a.id,
a.label,
a.ip_cidr,
a.note,
COALESCE(a.default_methods, 'WRITE') AS default_methods,
COALESCE(a.is_enabled, 1) AS is_enabled,
a.created_at,
a.updated_at,
(
SELECT COUNT(*)
FROM app_ip_access_rules r
WHERE r.address_id = a.id
) AS assigned_count,
(
SELECT COUNT(*)
FROM app_ip_access_rules r
WHERE r.address_id = a.id AND COALESCE(r.is_enabled, 1) = 1
) AS assigned_enabled_count
FROM ip_addresses a
ORDER BY a.label COLLATE NOCASE, a.id
"""
).fetchall()
con.close()
return [dict(row) for row in rows]
def get_ip_address(address_id: int) -> dict | None:
run_migrations()
con = get_connection()
row = con.execute(
"""
SELECT
id,
label,
ip_cidr,
note,
COALESCE(default_methods, 'WRITE') AS default_methods,
COALESCE(is_enabled, 1) AS is_enabled,
created_at,
updated_at
FROM ip_addresses
WHERE id = ?
""",
(address_id,),
).fetchone()
con.close()
return dict(row) if row else None
def find_ip_address_by_cidr(ip_cidr: str) -> dict | None:
run_migrations()
con = get_connection()
row = con.execute(
"""
SELECT id, label, ip_cidr
FROM ip_addresses
WHERE ip_cidr = ?
ORDER BY id
LIMIT 1
""",
(ip_cidr,),
).fetchone()
con.close()
return dict(row) if row else None
def create_ip_address(label: str, ip_cidr: str, note: str, default_methods: str, is_enabled: bool) -> int:
run_migrations()
con = get_connection()
con.execute(
"""
INSERT INTO ip_addresses (label, ip_cidr, note, default_methods, is_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(label, ip_cidr, note, default_methods, 1 if is_enabled else 0),
)
address_id = int(con.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
con.commit()
con.close()
return address_id
def update_ip_address(address_id: int, label: str, ip_cidr: str, note: str, default_methods: str, is_enabled: bool) -> int:
"""Uloží adresu a propíše novou IP/CIDR i popis do všech jejích přiřazení.
Vrací počet přiřazených pravidel, kterých se propsání dotklo — kvůli hlášce pro uživatele
(změna adresy se projeví ve všech službách, kde je přiřazená).
"""
run_migrations()
con = get_connection()
con.execute(
"""
UPDATE ip_addresses
SET label = ?,
ip_cidr = ?,
note = ?,
default_methods = ?,
is_enabled = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(label, ip_cidr, note, default_methods, 1 if is_enabled else 0, address_id),
)
# Přiřazení drží kopii IP/CIDR (kvůli generátoru Caddyfile), takže se musí přepsat spolu s
# katalogem. Vypnutá adresa vypne i všechna svá pravidla, zapnutí je nechává na uživateli.
cursor = con.execute(
"""
UPDATE app_ip_access_rules
SET ip_cidr = ?,
description = ?,
is_enabled = CASE WHEN ? = 0 THEN 0 ELSE COALESCE(is_enabled, 1) END,
updated_at = CURRENT_TIMESTAMP
WHERE address_id = ?
""",
(ip_cidr, label, 1 if is_enabled else 0, address_id),
)
affected = cursor.rowcount or 0
con.commit()
con.close()
return affected
def delete_ip_address(address_id: int) -> int:
"""Smaže adresu z katalogu i všechna její přiřazení. Vrací počet smazaných přiřazení."""
run_migrations()
con = get_connection()
cursor = con.execute("DELETE FROM app_ip_access_rules WHERE address_id = ?", (address_id,))
affected = cursor.rowcount or 0
con.execute("DELETE FROM ip_addresses WHERE id = ?", (address_id,))
con.commit()
con.close()
return affected
def get_assignments() -> list[dict]:
"""Všechna přiřazení z katalogu (bez ručních pravidel) napříč službami."""
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT
r.id,
r.app_id,
r.address_id,
r.ip_cidr,
r.methods,
COALESCE(r.is_enabled, 1) AS is_enabled,
a.label,
COALESCE(app.name, r.app_id) AS app_name
FROM app_ip_access_rules r
JOIN ip_addresses a ON a.id = r.address_id
LEFT JOIN apps app ON app.id = r.app_id
ORDER BY a.label COLLATE NOCASE, r.app_id
"""
).fetchall()
con.close()
return [dict(row) for row in rows]
def get_manual_rules() -> list[dict]:
"""Pravidla zadaná ručně v detailu služby (bez vazby na katalog) — kandidáti na import."""
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT
r.id,
r.app_id,
r.ip_cidr,
r.methods,
r.description,
COALESCE(r.is_enabled, 1) AS is_enabled,
COALESCE(app.name, r.app_id) AS app_name
FROM app_ip_access_rules r
LEFT JOIN apps app ON app.id = r.app_id
WHERE r.address_id IS NULL
ORDER BY r.ip_cidr, r.app_id
"""
).fetchall()
con.close()
return [dict(row) for row in rows]
def _sync_assignments(con, pairs: dict[tuple[int, str], tuple[str, bool]], scope_column: str, scope_value) -> dict[str, int]:
"""Srovná přiřazení v jednom rozsahu (jedna adresa, nebo jedna služba) s požadovaným stavem.
``pairs`` mapuje (address_id, app_id) -> (methods, is_enabled) pro přiřazení, která mají po
uložení existovat. Cokoli navázaného na katalog v daném rozsahu a chybějícího v ``pairs`` se
smaže. Vrací počty created/updated/deleted pro hlášku a audit.
"""
existing_rows = con.execute(
f"""
SELECT id, address_id, app_id, methods, COALESCE(is_enabled, 1) AS is_enabled
FROM app_ip_access_rules
WHERE address_id IS NOT NULL AND {scope_column} = ?
""",
(scope_value,),
).fetchall()
existing = {(int(row["address_id"]), row["app_id"]): dict(row) for row in existing_rows}
counts = {"created": 0, "updated": 0, "deleted": 0}
for key, row in existing.items():
if key not in pairs:
con.execute("DELETE FROM app_ip_access_rules WHERE id = ?", (row["id"],))
counts["deleted"] += 1
for (address_id, app_id), (methods, is_enabled) in pairs.items():
address = con.execute(
"SELECT label, ip_cidr, COALESCE(is_enabled, 1) AS is_enabled FROM ip_addresses WHERE id = ?",
(address_id,),
).fetchone()
if not address:
# Adresa mezitím zmizela (paralelní smazání) přiřazení nemá kam ukazovat, přeskoč.
continue
# Vypnutá adresa nesmí vzniknout jako aktivní pravidlo, i kdyby to formulář poslal.
effective_enabled = bool(is_enabled) and bool(address["is_enabled"])
row = existing.get((address_id, app_id))
if row is None:
con.execute(
"""
INSERT INTO app_ip_access_rules (
app_id, address_id, ip_cidr, methods, description, is_enabled, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(app_id, address_id, address["ip_cidr"], methods, address["label"], 1 if effective_enabled else 0),
)
counts["created"] += 1
elif row["methods"] != methods or bool(row["is_enabled"]) != effective_enabled:
con.execute(
"""
UPDATE app_ip_access_rules
SET ip_cidr = ?,
methods = ?,
description = ?,
is_enabled = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(address["ip_cidr"], methods, address["label"], 1 if effective_enabled else 0, row["id"]),
)
counts["updated"] += 1
return counts
def set_address_assignments(address_id: int, assignments: dict[str, tuple[str, bool]]) -> dict[str, int]:
"""Nastaví, do kterých služeb je adresa přiřazená. ``assignments``: app_id -> (methods, enabled)."""
run_migrations()
con = get_connection()
pairs = {(address_id, app_id): value for app_id, value in assignments.items()}
counts = _sync_assignments(con, pairs, "address_id", address_id)
con.commit()
con.close()
return counts
def set_app_assignments(app_id: str, assignments: dict[int, tuple[str, bool]]) -> dict[str, int]:
"""Nastaví, které adresy má služba přiřazené. ``assignments``: address_id -> (methods, enabled)."""
run_migrations()
con = get_connection()
pairs = {(address_id, app_id): value for address_id, value in assignments.items()}
counts = _sync_assignments(con, pairs, "app_id", app_id)
con.commit()
con.close()
return counts
def import_manual_rules(rule_ids: list[int]) -> dict[str, int]:
"""Naváže vybraná ruční pravidla na katalog (adresu podle IP/CIDR najde, nebo založí).
Vrací počty vytvořených adres a navázaných pravidel.
"""
run_migrations()
con = get_connection()
counts = {"addresses_created": 0, "rules_linked": 0}
for rule_id in rule_ids:
rule = con.execute(
"""
SELECT id, ip_cidr, methods, description
FROM app_ip_access_rules
WHERE id = ? AND address_id IS NULL
""",
(rule_id,),
).fetchone()
if not rule:
continue
address = con.execute(
"SELECT id, label FROM ip_addresses WHERE ip_cidr = ? ORDER BY id LIMIT 1",
(rule["ip_cidr"],),
).fetchone()
if address:
address_id = int(address["id"])
label = address["label"]
else:
label = (rule["description"] or "").strip() or rule["ip_cidr"]
con.execute(
"""
INSERT INTO ip_addresses (label, ip_cidr, note, default_methods, is_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(label, rule["ip_cidr"], "Importováno z pravidla v detailu služby.", rule["methods"] or "WRITE"),
)
address_id = int(con.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
counts["addresses_created"] += 1
con.execute(
"""
UPDATE app_ip_access_rules
SET address_id = ?,
description = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(address_id, label, rule["id"]),
)
counts["rules_linked"] += 1
con.commit()
con.close()
return counts
+23
View File
@@ -285,5 +285,28 @@ def run_migrations():
)
con.execute("CREATE INDEX IF NOT EXISTS idx_app_ip_access_rules_app_id ON app_ip_access_rules(app_id)")
# Pojmenovaný katalog IP adres. Jedna adresa (např. "Klient X kancelář") se přiřazuje do N
# služeb; přiřazení zůstává řádkem v app_ip_access_rules (zdroj pravdy pro generátor Caddyfile),
# jen s vazbou address_id na katalog. Ručně zadaná pravidla mají address_id = NULL.
con.execute(
"""
CREATE TABLE IF NOT EXISTS ip_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
ip_cidr TEXT NOT NULL,
note TEXT,
default_methods TEXT NOT NULL DEFAULT 'WRITE',
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""
)
con.execute("CREATE INDEX IF NOT EXISTS idx_ip_addresses_label ON ip_addresses(label)")
if "address_id" not in _table_columns(con, "app_ip_access_rules"):
con.execute("ALTER TABLE app_ip_access_rules ADD COLUMN address_id INTEGER")
con.execute("CREATE INDEX IF NOT EXISTS idx_app_ip_access_rules_address_id ON app_ip_access_rules(address_id)")
con.commit()
con.close()
+2 -1
View File
@@ -6,7 +6,7 @@ from starlette.middleware.sessions import SessionMiddleware
from .config import read_env_bool, read_env_value
from .logging_config import configure_logging
from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, jobs, logs, operations, runtime, scheduled_scripts, users, workers
from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, ip_access, jobs, logs, operations, runtime, scheduled_scripts, users, workers
def create_app() -> FastAPI:
@@ -42,6 +42,7 @@ def create_app() -> FastAPI:
app.include_router(audit.router)
app.include_router(runtime.router)
app.include_router(environment.router)
app.include_router(ip_access.router)
return app
+37 -6
View File
@@ -759,11 +759,27 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
row_class = "" if rule_enabled else " rule-disabled"
enabled_checked = bool_checked(rule_enabled)
ip_label = html.escape(rule.get("ip_cidr", "") or "")
# Pravidla navázaná na katalog IP adres drží IP/CIDR i popis podle katalogu, aby se obě
# místa nerozešla tady jsou proto jen ke čtení a mění se v centrální správě IP Access.
address_id = rule.get("address_id")
if address_id:
address_label = html.escape(rule.get("address_label") or rule.get("description") or "", quote=True)
source_cell = (
f'<a href="/portal/admin/ip-access?mode=address&amp;address_id={html.escape(str(address_id), quote=True)}" '
f'title="Spravovat v centrální správě IP Access">{address_label}</a>'
)
ip_cell = f'<input name="ip_cidr" value="{rule_ip}" form="{form_id}" readonly>'
description_cell = f'<input name="description" value="{rule_desc}" form="{form_id}" readonly>'
else:
source_cell = '<span class="muted">ručně</span>'
ip_cell = f'<input name="ip_cidr" value="{rule_ip}" form="{form_id}" required>'
description_cell = f'<input name="description" value="{rule_desc}" form="{form_id}">'
ip_access_rule_rows += f"""
<tr class="ip-rule-row{row_class}">
<td><input name="ip_cidr" value="{rule_ip}" form="{form_id}" required></td>
<td>{ip_cell}</td>
<td><select name="methods" form="{form_id}">{render_method_options(rule.get("methods", ""))}</select></td>
<td><input name="description" value="{rule_desc}" form="{form_id}"></td>
<td>{description_cell}</td>
<td>{source_cell}</td>
<td><label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" form="{form_id}"{enabled_checked}> Aktivní</label></td>
<td>
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/update" id="{form_id}" class="inline-form"></form>
@@ -776,7 +792,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
"""
if not ip_access_rule_rows:
ip_access_rule_rows = '<tr><td colspan="5">Zatím nejsou evidovaná žádná IP access pravidla.</td></tr>'
ip_access_rule_rows = '<tr><td colspan="6">Zatím nejsou evidovaná žádná IP access pravidla.</td></tr>'
notice = ""
if message:
@@ -908,11 +924,19 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
<strong>WRITE</strong> = POST, PUT, PATCH, DELETE; <strong>ALL</strong> = všechny běžné metody včetně GET.
Pravidla se zatím pouze ukládají (Caddy je začne vynucovat později).
</p>
<p class="inline-form">
<a class="btn btn-secondary" href="/portal/admin/ip-access?mode=service&amp;app_id={app_url_id}">
<i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Hromadné přiřazení IP adres
</a>
<span class="muted">Pojmenované adresy z katalogu (sloupec Zdroj) se spravují centrálně —
tam jednou adresou obsloužíte i deset služeb.</span>
</p>
<table>
<tr>
<th>IP/CIDR</th>
<th>Methods</th>
<th>Description</th>
<th>Zdroj</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
@@ -1281,12 +1305,19 @@ def save_app_ip_access_rule(
rule_ip = clean_optional(ip_cidr)
rule_methods = clean_optional(methods).upper()
if not rule_ip:
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
rule_description = description.strip()
if not rule_methods:
return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.")
update_app_ip_access_rule(rule_id, app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled))
# Pravidla z katalogu IP adres si IP/CIDR ani popis nedrží sama ty patří katalogu, jinak by
# se obě místa rozešla. Editovatelné odtud zůstávají jen metody a zapnutí/vypnutí.
if existing.get("address_id"):
rule_ip = existing.get("ip_cidr") or ""
rule_description = existing.get("description") or ""
elif not rule_ip:
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
update_app_ip_access_rule(rule_id, app_id, rule_ip, rule_methods, rule_description, bool(is_enabled))
log_audit_event(
user,
action="app.ip_access_rule.updated",
+759
View File
@@ -0,0 +1,759 @@
"""Centrální správa IP access — katalog pojmenovaných IP adres + hromadné přiřazování ke službám.
Doplněk k per-service kartě v detailu služby: adresu ("Klient X kancelář") založím jednou
a pak ji jedním formulářem přiřadím do libovolného počtu služeb. Přiřazení se ukládá do
existující tabulky ``app_ip_access_rules``, ze které generuje pravidla Caddyfile — ta zůstává
beze změny zdrojem pravdy, přibyla jen vazba ``address_id`` na katalog.
"""
import html
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from ..auth import is_admin, require_developer
from ..db.apps import get_apps
from ..db.audit import log_audit_event
from ..db.ip_access import (
create_ip_address,
delete_ip_address,
get_assignments,
get_ip_address,
get_ip_addresses,
get_manual_rules,
import_manual_rules,
set_address_assignments,
set_app_assignments,
update_ip_address,
)
from ..routes.apps import IP_ACCESS_METHOD_OPTIONS
from ..routes.runtime import render_action_buttons
from ..templates.layout import page
router = APIRouter()
PAGE_URL = "/portal/admin/ip-access"
MODE_ADDRESS = "address"
MODE_SERVICE = "service"
def _clean(value: str | None) -> str:
return (value or "").strip()
def _checked(value) -> str:
return " checked" if value else ""
def _method_options(selected: str) -> str:
selected_upper = _clean(selected).upper()
options = list(IP_ACCESS_METHOD_OPTIONS)
if selected_upper and selected_upper not in options:
options.append(selected_upper)
out = ""
for opt in options:
is_sel = " selected" if opt == selected_upper else ""
out += f'<option value="{html.escape(opt, quote=True)}"{is_sel}>{html.escape(opt)}</option>'
return out
def _redirect(mode: str, address_id=None, app_id=None, message: str = "", error: str = ""):
params: dict[str, str] = {"mode": mode}
if address_id:
params["address_id"] = str(address_id)
if app_id:
params["app_id"] = str(app_id)
if message:
params["message"] = message
if error:
params["error"] = error
return RedirectResponse(url=f"{PAGE_URL}?{urlencode(params)}", status_code=303)
async def form_lists(request: Request) -> dict[str, list[str]]:
"""Opakovaná pole formuláře (matice přiřazení) načtená přímo z requestu.
``list[str] = Form(...)`` se napříč verzemi FastAPI chová u opakovaných polí nejednotně,
``form.getlist()`` je spolehlivé. Async dependency + sync endpoint znamená, že samotný
handler (a jeho blokující sqlite dotazy) dál běží v threadpoolu jako zbytek portálu.
"""
form = await request.form()
return {name: form.getlist(name) for name in ("row_key", "row_methods", "assigned_keys", "rule_ids")}
def _parse_rows(rows: dict[str, list[str]]) -> dict[str, str]:
"""Z paralelních polí formuláře vytáhne {klíč řádku: metody} jen pro zaškrtnuté řádky.
Skryté ``row_key`` + ``row_methods`` se posílají za každý řádek (proto jsou zarovnané),
zaškrtnutá políčka chodí zvlášť v ``assigned_keys`` — nezaškrtnutý checkbox prohlížeč
neodešle, takže by pole rozházel.
"""
checked = set(rows.get("assigned_keys") or [])
methods_by_key: dict[str, str] = {}
for key, methods in zip(rows.get("row_key") or [], rows.get("row_methods") or []):
methods_by_key[key] = _clean(methods).upper() or "WRITE"
return {key: methods_by_key.get(key, "WRITE") for key in checked if key in methods_by_key}
# --- render -----------------------------------------------------------------
def _render_catalog_card(addresses: list[dict]) -> str:
rows = ""
for address in addresses:
aid = html.escape(str(address.get("id", "")))
form_id = f"ipaddr-{aid}"
label = html.escape(address.get("label", "") or "", quote=True)
ip_cidr = html.escape(address.get("ip_cidr", "") or "", quote=True)
note = html.escape(address.get("note", "") or "", quote=True)
enabled = bool(address.get("is_enabled", 1))
assigned = int(address.get("assigned_count") or 0)
row_class = "" if enabled else " rule-disabled"
rows += f"""
<tr class="ip-rule-row{row_class}">
<td><input name="label" value="{label}" form="{form_id}" required></td>
<td><input name="ip_cidr" value="{ip_cidr}" form="{form_id}" required></td>
<td><select name="default_methods" form="{form_id}">{_method_options(address.get("default_methods", ""))}</select></td>
<td><input name="note" value="{note}" form="{form_id}"></td>
<td><label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" form="{form_id}"{_checked(enabled)}> Aktivní</label></td>
<td><a class="pill" href="{PAGE_URL}?mode={MODE_ADDRESS}&amp;address_id={aid}">{assigned} služeb</a></td>
<td class="actions-cell">
<form method="post" action="{PAGE_URL}/addresses/{aid}/update" id="{form_id}" class="inline-form"></form>
<button type="submit" form="{form_id}" class="icon-action" title="Uložit" aria-label="Uložit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i></button>
<form method="post" action="{PAGE_URL}/addresses/{aid}/delete" class="inline-form"
onsubmit="return confirm('Smazat adresu {label} včetně {assigned} přiřazení?');">
<button type="submit" class="icon-action icon-action-danger" title="Smazat" aria-label="Smazat"><i class="fa-solid fa-trash" aria-hidden="true"></i></button>
</form>
</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="7">Katalog je zatím prázdný — přidejte první IP adresu níže.</td></tr>'
return f"""
<div class="card" id="adresy">
<h2><i class="fa-solid fa-address-book" aria-hidden="true"></i> IP adresy</h2>
<p class="muted">
Pojmenovaný katalog adres. Změna IP/CIDR nebo názvu se automaticky propíše do všech
služeb, kde je adresa přiřazená. Vypnutím adresy odeberete přístup všude naráz —
přiřazení zůstanou zachovaná pro pozdější zapnutí.
</p>
<table>
<tr>
<th>Název</th>
<th>IP/CIDR</th>
<th>Výchozí metody</th>
<th>Poznámka</th>
<th>Stav</th>
<th>Přiřazeno</th>
<th>Akce</th>
</tr>
{rows}
</table>
<form method="post" action="{PAGE_URL}/addresses" class="metadata-form add-variable-form">
<h3>Přidat IP adresu</h3>
<label>Název</label>
<input name="label" placeholder="např. Klient Novák kancelář" required>
<label>IP/CIDR</label>
<input name="ip_cidr" placeholder="např. 185.10.20.30 nebo 10.0.0.0/24" required>
<label>Výchozí metody</label>
<select name="default_methods">{_method_options("WRITE")}</select>
<label>Poznámka</label>
<input name="note" placeholder="např. statická IP, kontakt: Jan Novák">
<label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" checked> Aktivní</label>
<div class="form-actions">
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat adresu</button>
</div>
</form>
</div>
"""
def _render_assignment_card(
mode: str,
addresses: list[dict],
apps: list[dict],
assignment_map: dict[tuple[int, str], dict],
selected_address: dict | None,
selected_app: dict | None,
) -> str:
address_tab = "btn" if mode == MODE_ADDRESS else "btn btn-secondary"
service_tab = "btn" if mode == MODE_SERVICE else "btn btn-secondary"
selected_address_id = int(selected_address["id"]) if selected_address else 0
selected_app_id = selected_app.get("id", "") if selected_app else ""
switcher = f"""
<div class="detail-tabs" aria-label="Režim přiřazování">
<a class="{address_tab}" href="{PAGE_URL}?mode={MODE_ADDRESS}{f'&amp;address_id={selected_address_id}' if selected_address_id else ''}">
<i class="fa-solid fa-location-crosshairs" aria-hidden="true"></i> Podle IP adresy
</a>
<a class="{service_tab}" href="{PAGE_URL}?mode={MODE_SERVICE}{f'&amp;app_id={html.escape(selected_app_id, quote=True)}' if selected_app_id else ''}">
<i class="fa-solid fa-server" aria-hidden="true"></i> Podle služby
</a>
</div>
"""
if mode == MODE_ADDRESS:
if not addresses:
return f"""
<div class="card" id="prirazeni">
<h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Přiřazení</h2>
{switcher}
<p class="muted">Nejdřív přidejte alespoň jednu IP adresu do katalogu.</p>
</div>
"""
options = "".join(
'<option value="{value}"{sel}>{text}</option>'.format(
value=int(item["id"]),
sel=" selected" if int(item["id"]) == selected_address_id else "",
text=html.escape(
"{label} ({ip}){off}".format(
label=item.get("label", "") or "",
ip=item.get("ip_cidr", "") or "",
off="" if item.get("is_enabled", 1) else " vypnuto",
)
),
)
for item in addresses
)
selector = f"""
<form method="get" action="{PAGE_URL}" class="inline-form">
<input type="hidden" name="mode" value="{MODE_ADDRESS}">
<label for="address-switch"><strong>IP adresa</strong></label>
<select id="address-switch" name="address_id" onchange="this.form.submit()">{options}</select>
<button type="submit" class="btn-secondary"><i class="fa-solid fa-arrow-right-arrow-left" aria-hidden="true"></i> Přepnout</button>
</form>
"""
disabled_note = ""
if not selected_address.get("is_enabled", 1):
disabled_note = (
'<p class="alert">Adresa je vypnutá — přiřazení se ukládají, ale všechna pravidla '
"zůstanou neaktivní, dokud adresu nezapnete v katalogu výše.</p>"
)
row_html = ""
for item in apps:
app_id = item.get("id", "") or ""
key = html.escape(app_id, quote=True)
rule = assignment_map.get((selected_address_id, app_id))
methods = rule.get("methods") if rule else selected_address.get("default_methods", "WRITE")
row_html += f"""
<tr>
<td><input type="hidden" name="row_key" value="{key}">
<label class="checkbox-label"><input type="checkbox" name="assigned_keys" value="{key}"{_checked(rule)}> <span class="sr-only">Přiřadit</span></label></td>
<td><a href="/portal/apps/{key}#ip-access">{html.escape(item.get("name", "") or app_id)}</a>
<div class="muted">{html.escape(app_id)}</div></td>
<td><select name="row_methods">{_method_options(methods)}</select></td>
</tr>
"""
if not row_html:
row_html = '<tr><td colspan="3">Zatím nejsou evidované žádné služby.</td></tr>'
action = f"{PAGE_URL}/addresses/{selected_address_id}/assignments"
heading = "Do kterých služeb pustit adresu <strong>{label}</strong> ({ip})".format(
label=html.escape(selected_address.get("label", "") or ""),
ip=html.escape(selected_address.get("ip_cidr", "") or ""),
)
first_column = "Přiřadit"
second_column = "Služba"
else:
if not apps:
return f"""
<div class="card" id="prirazeni">
<h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Přiřazení</h2>
{switcher}
<p class="muted">Zatím nejsou evidované žádné služby.</p>
</div>
"""
options = "".join(
'<option value="{value}"{sel}>{text}</option>'.format(
value=html.escape(item.get("id", "") or "", quote=True),
sel=" selected" if (item.get("id", "") or "") == selected_app_id else "",
text=html.escape("{name} ({id})".format(name=item.get("name", "") or item.get("id", ""), id=item.get("id", ""))),
)
for item in apps
)
selector = f"""
<form method="get" action="{PAGE_URL}" class="inline-form">
<input type="hidden" name="mode" value="{MODE_SERVICE}">
<label for="app-switch"><strong>Služba</strong></label>
<select id="app-switch" name="app_id" onchange="this.form.submit()">{options}</select>
<button type="submit" class="btn-secondary"><i class="fa-solid fa-arrow-right-arrow-left" aria-hidden="true"></i> Přepnout</button>
</form>
"""
disabled_note = ""
row_html = ""
for item in addresses:
address_id = int(item["id"])
key = str(address_id)
rule = assignment_map.get((address_id, selected_app_id))
methods = rule.get("methods") if rule else item.get("default_methods", "WRITE")
off_pill = "" if item.get("is_enabled", 1) else ' <span class="pill pill-muted">vypnuto</span>'
row_html += f"""
<tr>
<td><input type="hidden" name="row_key" value="{key}">
<label class="checkbox-label"><input type="checkbox" name="assigned_keys" value="{key}"{_checked(rule)}> <span class="sr-only">Přiřadit</span></label></td>
<td>{html.escape(item.get("label", "") or "")}{off_pill}
<div class="muted">{html.escape(item.get("ip_cidr", "") or "")}</div></td>
<td><select name="row_methods">{_method_options(methods)}</select></td>
</tr>
"""
if not row_html:
row_html = '<tr><td colspan="3">Katalog je zatím prázdný — přidejte první IP adresu výše.</td></tr>'
action = f"{PAGE_URL}/apps/{html.escape(selected_app_id, quote=True)}/assignments"
heading = "Které IP adresy pustit do služby <strong>{name}</strong>".format(
name=html.escape(selected_app.get("name", "") or selected_app_id),
)
first_column = "Přiřadit"
second_column = "IP adresa"
return f"""
<div class="card" id="prirazeni">
<h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Přiřazení</h2>
{switcher}
{selector}
<p>{heading}</p>
{disabled_note}
<form method="post" action="{action}" id="assignment-form">
<div class="inline-form assignment-tools">
<button type="button" class="btn-secondary" onclick="ipAccessToggleAll(true)"><i class="fa-solid fa-check-double" aria-hidden="true"></i> Označit vše</button>
<button type="button" class="btn-secondary" onclick="ipAccessToggleAll(false)"><i class="fa-solid fa-xmark" aria-hidden="true"></i> Odznačit vše</button>
<label for="bulk-methods">Nastavit metody všem</label>
<select id="bulk-methods" onchange="ipAccessApplyMethods(this.value)">
<option value="">— vyberte —</option>
{_method_options("")}
</select>
</div>
<table>
<tr>
<th style="width: 1%">{first_column}</th>
<th>{second_column}</th>
<th style="width: 30%">Metody</th>
</tr>
{row_html}
</table>
<div class="form-actions">
<button type="submit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit přiřazení</button>
</div>
</form>
<p class="muted">
Odškrtnuté řádky se při uložení smažou. Metody: <strong>WRITE</strong> = POST, PUT, PATCH,
DELETE; <strong>ALL</strong> = všechny běžné metody včetně GET.
</p>
</div>
"""
def _render_overview_card(addresses: list[dict], assignments: list[dict]) -> str:
by_address: dict[int, list[dict]] = {}
for item in assignments:
by_address.setdefault(int(item["address_id"]), []).append(item)
rows = ""
for address in addresses:
address_id = int(address["id"])
items = by_address.get(address_id, [])
if items:
pills = "".join(
'<a class="pill{muted}" href="/portal/apps/{app}#ip-access" title="{methods}">{name}</a> '.format(
muted="" if item.get("is_enabled", 1) else " pill-muted",
app=html.escape(item.get("app_id", "") or "", quote=True),
methods=html.escape(item.get("methods", "") or "", quote=True),
name=html.escape(item.get("app_name", "") or item.get("app_id", "") or ""),
)
for item in items
)
else:
pills = '<span class="muted">bez přiřazení</span>'
state = "" if address.get("is_enabled", 1) else ' <span class="pill pill-muted">vypnuto</span>'
rows += f"""
<tr>
<td>{html.escape(address.get("label", "") or "")}{state}<div class="muted">{html.escape(address.get("ip_cidr", "") or "")}</div></td>
<td>{pills}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="2">Katalog je zatím prázdný.</td></tr>'
return f"""
<div class="card" id="prehled">
<h2><i class="fa-solid fa-table-list" aria-hidden="true"></i> Přehled</h2>
<table>
<tr>
<th style="width: 30%">IP adresa</th>
<th>Přiřazené služby</th>
</tr>
{rows}
</table>
</div>
"""
def _render_manual_card(manual_rules: list[dict]) -> str:
if not manual_rules:
return ""
rows = ""
for rule in manual_rules:
rid = html.escape(str(rule.get("id", "")))
app_id = html.escape(rule.get("app_id", "") or "", quote=True)
rows += f"""
<tr>
<td><label class="checkbox-label"><input type="checkbox" name="rule_ids" value="{rid}"> <span class="sr-only">Importovat</span></label></td>
<td><a href="/portal/apps/{app_id}#ip-access">{html.escape(rule.get("app_name", "") or rule.get("app_id", "") or "")}</a></td>
<td>{html.escape(rule.get("ip_cidr", "") or "")}</td>
<td>{html.escape(rule.get("methods", "") or "")}</td>
<td>{html.escape(rule.get("description", "") or "")}</td>
<td>{"Ano" if rule.get("is_enabled", 1) else "Ne"}</td>
</tr>
"""
return f"""
<div class="card" id="rucni">
<h2><i class="fa-solid fa-file-import" aria-hidden="true"></i> Pravidla mimo katalog</h2>
<p class="muted">
Pravidla zadaná přímo v detailu služby. Fungují dál beze změny; importem se jen
navážou na katalog (adresa se najde podle IP/CIDR, jinak se založí), aby šly spravovat
hromadně odtud.
</p>
<form method="post" action="{PAGE_URL}/manual-rules/import">
<table>
<tr>
<th style="width: 1%">Import</th>
<th>Služba</th>
<th>IP/CIDR</th>
<th>Metody</th>
<th>Popis</th>
<th>Aktivní</th>
</tr>
{rows}
</table>
<div class="form-actions">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-file-import" aria-hidden="true"></i> Importovat vybraná do katalogu</button>
</div>
</form>
</div>
"""
def _render_apply_card(user: dict) -> str:
if not is_admin(user):
return """
<div class="card" id="aplikovat">
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Aplikovat na gateway</h2>
<p class="muted">
Změny jsou uložené v databázi. Pregenerování Caddy může spustit jen administrátor
(Admin → Environment → Regenerate Caddy).
</p>
</div>
"""
return f"""
<div class="card" id="aplikovat">
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Aplikovat na gateway</h2>
<p class="muted">
Uložením se změní jen databáze. Aby se pravidla projevila na gateway, spusťte
pregenerování Caddyfile — akce nevolá docker přímo, vytvoří úlohu do fronty
(zpracuje worker). Historii najdete v <a href="/portal/jobs">Úlohách</a>.
</p>
<div class="inline-form">
{render_action_buttons(["regenerate-caddy"], PAGE_URL)}
</div>
</div>
"""
def _render_page(user: dict, mode: str, address_id: str, app_id: str, message: str = "", error: str = "") -> str:
addresses = get_ip_addresses()
apps = get_apps()
assignments = get_assignments()
manual_rules = get_manual_rules()
assignment_map = {(int(item["address_id"]), item["app_id"]): item for item in assignments}
mode = MODE_SERVICE if mode == MODE_SERVICE else MODE_ADDRESS
selected_address = None
if addresses:
selected_address = next((item for item in addresses if str(item["id"]) == str(address_id)), addresses[0])
selected_app = None
if apps:
selected_app = next((item for item in apps if str(item.get("id")) == str(app_id)), apps[0])
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
total_assignments = len(assignments)
active_addresses = sum(1 for item in addresses if item.get("is_enabled", 1))
body = f"""
<div class="card">
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> IP Access</h2>
<p class="muted">
Centrální správa přístupů podle IP. Adresu založíte jednou v katalogu a pak ji
přepínačem níže přiřadíte do libovolného počtu služeb — typicky dvě IP klienta do
deseti služeb. Přiřazení se ukládají do stejných pravidel, která používá detail
služby i generátor Caddyfile.
</p>
{notice}
<div class="stats-grid">
<div class="stat-card"><span>IP adres v katalogu</span><strong>{len(addresses)}</strong></div>
<div class="stat-card"><span>Aktivních adres</span><strong>{active_addresses}</strong></div>
<div class="stat-card"><span>Přiřazení celkem</span><strong>{total_assignments}</strong></div>
<div class="stat-card"><span>Pravidel mimo katalog</span><strong>{len(manual_rules)}</strong></div>
</div>
</div>
{_render_assignment_card(mode, addresses, apps, assignment_map, selected_address, selected_app)}
{_render_catalog_card(addresses)}
{_render_overview_card(addresses, assignments)}
{_render_manual_card(manual_rules)}
{_render_apply_card(user)}
<script>
function ipAccessToggleAll(checked) {{
const form = document.getElementById("assignment-form");
if (!form) return;
form.querySelectorAll('input[name="assigned_keys"]').forEach(function (box) {{
box.checked = checked;
}});
}}
function ipAccessApplyMethods(value) {{
if (!value) return;
const form = document.getElementById("assignment-form");
if (!form) return;
form.querySelectorAll('select[name="row_methods"]').forEach(function (select) {{
select.value = value;
}});
}}
</script>
"""
return page("IP Access", body, user=user)
# --- endpoints --------------------------------------------------------------
@router.get("/admin/ip-access", response_class=HTMLResponse)
def ip_access_page(
request: Request,
mode: str = MODE_ADDRESS,
address_id: str = "",
app_id: str = "",
message: str = "",
error: str = "",
user=Depends(require_developer),
):
return _render_page(user, mode, address_id, app_id, message=message, error=error)
@router.post("/admin/ip-access/addresses")
def add_ip_address(
label: str = Form(...),
ip_cidr: str = Form(...),
default_methods: str = Form("WRITE"),
note: str = Form(""),
is_enabled: str | None = Form(None),
user=Depends(require_developer),
):
clean_label = _clean(label)
clean_ip = _clean(ip_cidr)
clean_methods = _clean(default_methods).upper() or "WRITE"
if not clean_label:
return _redirect(MODE_ADDRESS, error="Název adresy nesmí být prázdný.")
if not clean_ip:
return _redirect(MODE_ADDRESS, error="IP/CIDR nesmí být prázdné.")
address_id = create_ip_address(clean_label, clean_ip, _clean(note), clean_methods, bool(is_enabled))
log_audit_event(
user,
action="ip_access.address.created",
target_type="ip_address",
target_id=str(address_id),
metadata={"label": clean_label, "ip_cidr": clean_ip, "default_methods": clean_methods, "is_enabled": bool(is_enabled)},
)
return _redirect(MODE_ADDRESS, address_id=address_id, message=f"IP adresa {clean_label} přidána do katalogu.")
@router.post("/admin/ip-access/addresses/{address_id}/update")
def save_ip_address(
address_id: int,
label: str = Form(...),
ip_cidr: str = Form(...),
default_methods: str = Form("WRITE"),
note: str = Form(""),
is_enabled: str | None = Form(None),
user=Depends(require_developer),
):
existing = get_ip_address(address_id)
if not existing:
return _redirect(MODE_ADDRESS, error="IP adresa nebyla nalezena.")
clean_label = _clean(label)
clean_ip = _clean(ip_cidr)
clean_methods = _clean(default_methods).upper() or "WRITE"
if not clean_label:
return _redirect(MODE_ADDRESS, address_id=address_id, error="Název adresy nesmí být prázdný.")
if not clean_ip:
return _redirect(MODE_ADDRESS, address_id=address_id, error="IP/CIDR nesmí být prázdné.")
affected = update_ip_address(address_id, clean_label, clean_ip, _clean(note), clean_methods, bool(is_enabled))
log_audit_event(
user,
action="ip_access.address.updated",
target_type="ip_address",
target_id=str(address_id),
metadata={
"label": clean_label,
"ip_cidr": clean_ip,
"default_methods": clean_methods,
"is_enabled": bool(is_enabled),
"propagated_rules": affected,
},
)
note_text = f" Propsáno do {affected} přiřazených pravidel." if affected else ""
return _redirect(MODE_ADDRESS, address_id=address_id, message=f"IP adresa {clean_label} uložena.{note_text}")
@router.post("/admin/ip-access/addresses/{address_id}/delete")
def remove_ip_address(address_id: int, user=Depends(require_developer)):
existing = get_ip_address(address_id)
if not existing:
return _redirect(MODE_ADDRESS, error="IP adresa nebyla nalezena.")
affected = delete_ip_address(address_id)
log_audit_event(
user,
action="ip_access.address.deleted",
target_type="ip_address",
target_id=str(address_id),
metadata={"label": existing.get("label"), "ip_cidr": existing.get("ip_cidr"), "removed_rules": affected},
)
return _redirect(
MODE_ADDRESS,
message=f"IP adresa {existing.get('label')} smazána včetně {affected} přiřazení.",
)
@router.post("/admin/ip-access/addresses/{address_id}/assignments")
def save_address_assignments(
address_id: int,
rows: dict[str, list[str]] = Depends(form_lists),
user=Depends(require_developer),
):
address = get_ip_address(address_id)
if not address:
return _redirect(MODE_ADDRESS, error="IP adresa nebyla nalezena.")
selected = _parse_rows(rows)
known_apps = {item.get("id", "") for item in get_apps()}
unknown = sorted(set(selected) - known_apps)
if unknown:
return _redirect(
MODE_ADDRESS,
address_id=address_id,
error="Neznámé služby v požadavku: " + ", ".join(unknown),
)
counts = set_address_assignments(address_id, {app: (methods, True) for app, methods in selected.items()})
log_audit_event(
user,
action="ip_access.assignments.updated",
target_type="ip_address",
target_id=str(address_id),
metadata={"label": address.get("label"), "app_ids": sorted(selected), **counts},
)
return _redirect(
MODE_ADDRESS,
address_id=address_id,
message=(
f"Přiřazení adresy {address.get('label')} uloženo — {len(selected)} služeb "
f"(+{counts['created']} / ~{counts['updated']} / -{counts['deleted']}). "
"Nezapomeňte pregenerovat Caddy."
),
)
@router.post("/admin/ip-access/apps/{app_id}/assignments")
def save_app_ip_assignments(
app_id: str,
rows: dict[str, list[str]] = Depends(form_lists),
user=Depends(require_developer),
):
apps = get_apps()
app = next((item for item in apps if item.get("id") == app_id), None)
if not app:
return _redirect(MODE_SERVICE, error="Služba nebyla nalezena.")
selected = _parse_rows(rows)
known_addresses = {str(item["id"]) for item in get_ip_addresses()}
unknown = sorted(set(selected) - known_addresses)
if unknown:
return _redirect(
MODE_SERVICE,
app_id=app_id,
error="Neznámé IP adresy v požadavku: " + ", ".join(unknown),
)
counts = set_app_assignments(app_id, {int(key): (methods, True) for key, methods in selected.items()})
log_audit_event(
user,
action="ip_access.assignments.updated",
target_type="app",
target_id=app_id,
metadata={"address_ids": sorted(int(key) for key in selected), **counts},
)
return _redirect(
MODE_SERVICE,
app_id=app_id,
message=(
f"Přiřazení služby {app.get('name') or app_id} uloženo — {len(selected)} IP adres "
f"(+{counts['created']} / ~{counts['updated']} / -{counts['deleted']}). "
"Nezapomeňte pregenerovat Caddy."
),
)
@router.post("/admin/ip-access/manual-rules/import")
def import_manual_ip_rules(rows: dict[str, list[str]] = Depends(form_lists), user=Depends(require_developer)):
rule_ids = [int(value) for value in rows.get("rule_ids") or [] if value.isdigit()]
if not rule_ids:
return _redirect(MODE_ADDRESS, error="Nebyla vybraná žádná pravidla k importu.")
counts = import_manual_rules(rule_ids)
log_audit_event(
user,
action="ip_access.manual_rules.imported",
target_type="ip_address",
target_id=None,
metadata={"rule_ids": rule_ids, **counts},
)
return _redirect(
MODE_ADDRESS,
message=(
f"Importováno {counts['rules_linked']} pravidel, "
f"nově založeno {counts['addresses_created']} adres v katalogu."
),
)
+23
View File
@@ -483,6 +483,29 @@ button:hover,
background: #f3f5f7;
}
/* Pravidla z katalogu IP adres: IP/CIDR a popis se spravují centrálně, tady jen ke čtení. */
.ip-rule-row input[readonly] {
background: #f3f5f7;
color: var(--muted);
cursor: not-allowed;
}
/* Hromadné akce nad maticí přiřazení (označit vše / nastavit metody všem). */
.assignment-tools {
margin: 12px 0;
padding: 10px 12px;
background: #eef6f9;
border: 1px solid var(--border);
border-radius: 10px;
}
.assignment-tools label {
font-size: 13px;
font-weight: 700;
color: var(--secondary);
margin-left: 8px;
}
.info-dot {
position: relative;
display: inline-flex;
+5
View File
@@ -14,6 +14,7 @@ ADMIN_MENU_ITEMS = [
("Audit", "/portal/audit", "fa-clipboard-list", False),
("Environment", "/portal/admin/environment", "fa-sliders", True),
("Incidenty", "/portal/incidents", "fa-triangle-exclamation", False),
("IP Access", "/portal/admin/ip-access", "fa-shield-halved", False),
("Nasazení", "/portal/deployments", "fa-rocket", False),
("Plánované skripty", "/portal/scheduled-scripts", "fa-calendar-days", False),
("Služby", "/portal/apps", "fa-server", False),
@@ -58,6 +59,10 @@ def page(title: str, body: str, user=None) -> str:
'<a class="nav-link" href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>'
'<a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</a>'
)
# IP Access spravují i developeři, ale admin menu se jim nezobrazuje proto jen jim
# přidáváme odkaz do hlavní navigace, aby stránku nemuseli hledat přes detail služby.
if can_operate and role != "admin":
nav_links += '<a class="nav-link" href="/portal/admin/ip-access"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> IP Access</a>'
admin_menu = ""
if role == "admin":