Initial commit: PoE Manager modern UI rebuild
- Neues, eigenständiges Frontend (Sidebar, zentriertes Logo in der Topbar, Dark/Light-Theme, Karten-Dashboard, Toasts/Modals statt Bootstrap) - Oeffentliches Kurz-Dashboard ohne Login (Status-Uebersicht) - Browser-SSH-Terminal (paramiko, plattformunabhaengig) zum Testen von Switch-Zugangsdaten inkl. interaktiver Host-Key-Bestaetigung - Granulares Rechtesystem mit Gruppen (Devices/Switches-Berechtigungen) - Aufgeraeumtes Backend mit konfigurierbaren Pfaden, auto-generierten Secrets statt hart codierter Werte im Original Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title or "PoE Manager" }}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% set nav_items = [
|
||||
("index", "Dashboard", "grid", true),
|
||||
("devices", "Devices", "cpu", true),
|
||||
("switches", "Switches", "share", false),
|
||||
("users", "Users", "users", false),
|
||||
("logs", "Live-Log", "terminal", false),
|
||||
("settings", "Settings", "sliders", false),
|
||||
] %}
|
||||
|
||||
{% set icons = {
|
||||
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
||||
"cpu": '<rect x="6" y="6" width="12" height="12" rx="1.5"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>',
|
||||
"share": '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/>',
|
||||
"users": '<circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/>',
|
||||
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
|
||||
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
||||
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||||
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
|
||||
} %}
|
||||
|
||||
<div class="app-shell">
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="sidebar-backdrop" data-sidebar-toggle></div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div>
|
||||
<span class="brand-name">PoE Manager</span>
|
||||
<span class="brand-sub">Device Monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="{{ url_for('index') }}" class="nav-item {% if active_page == 'index' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['grid']|safe }}</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('devices') }}" class="nav-item {% if active_page == 'devices' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['cpu']|safe }}</svg>
|
||||
Devices
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('switches') }}" class="nav-item {% if active_page == 'switches' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['share']|safe }}</svg>
|
||||
Switches
|
||||
</a>
|
||||
<a href="{{ url_for('users') }}" class="nav-item {% if active_page == 'users' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['users']|safe }}</svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="{{ url_for('groups') }}" class="nav-item {% if active_page == 'groups' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['groups']|safe }}</svg>
|
||||
Gruppen
|
||||
</a>
|
||||
<a href="{{ url_for('logs') }}" class="nav-item {% if active_page == 'logs' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['terminal']|safe }}</svg>
|
||||
Live-Log
|
||||
</a>
|
||||
<a href="{{ url_for('settings') }}" class="nav-item {% if active_page == 'settings' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['sliders']|safe }}</svg>
|
||||
Settings
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-chip">
|
||||
<div class="user-avatar">{{ current_user.username[:2]|upper }}</div>
|
||||
<div class="user-meta">
|
||||
<div class="u-name">{{ current_user.username }}</div>
|
||||
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
|
||||
<span data-theme-icon></span>
|
||||
</button>
|
||||
<a href="{{ url_for('logout') }}" class="icon-btn" title="Abmelden" style="flex:1; display:flex;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:auto;">{{ icons['logout']|safe }}</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
||||
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
{% if current_user.is_authenticated %}
|
||||
<button type="button" class="hamburger" data-sidebar-toggle aria-label="Menü">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="topbar-title">{% block page_title %}Dashboard{% endblock %}</div>
|
||||
{% block page_sub %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-logo">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
{% block topbar_right %}{% endblock %}
|
||||
{% if not current_user.is_authenticated %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||
Login
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
<script type="application/json" id="flashed-data">{{ messages|tojson }}</script>
|
||||
{% endwith %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,267 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "devices" %}
|
||||
{% set can_toggle = current_user.has_permission('devices.toggle') %}
|
||||
{% set can_create = current_user.has_permission('devices.create') %}
|
||||
{% set can_edit = current_user.has_permission('devices.edit') %}
|
||||
{% set can_delete = current_user.has_permission('devices.delete') %}
|
||||
{% set show_actions_col = can_edit or can_delete %}
|
||||
{% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
|
||||
{% block page_title %}Devices{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
{% if can_create %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neues Gerät
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="deviceSearch" placeholder="Geräte durchsuchen…" oninput="filterTable('deviceSearch','devicesTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="devicesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>MAC-Adresse</th>
|
||||
<th>Switch</th>
|
||||
<th>Port</th>
|
||||
{% if can_toggle %}<th>Status</th>{% endif %}
|
||||
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ d['name'] }}</td>
|
||||
<td class="mono">{{ d['rpi_ip'] }}</td>
|
||||
<td class="mono">{{ d['mac'] }}</td>
|
||||
<td>{{ d['switch_hostname'] or '—' }}</td>
|
||||
<td>{{ d['port'] or '—' }}</td>
|
||||
{% if can_toggle %}
|
||||
<td>
|
||||
<label class="switch-check">
|
||||
<input type="checkbox" {% if d['is_active'] %}checked{% endif %} onchange="toggleDevice('{{ d['mac'] }}', this)">
|
||||
<span class="track"></span>
|
||||
</label>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if show_actions_col %}
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten"
|
||||
onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['rpi_ip'] }}','{{ d['port'] or '' }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Switch ändern"
|
||||
onclick="openSwitchModal('{{ d['mac'] }}','{{ d['switch_hostname'] or '' }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form method="post" data-confirm="Willst du „{{ d['name'] }}“ wirklich löschen?">
|
||||
<input type="hidden" name="delete_device" value="{{ d['mac'] }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="{{ col_count }}">Noch keine Geräte vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create %}
|
||||
<!-- Modal: Neues Gerät -->
|
||||
<div class="modal-overlay" id="deviceModal">
|
||||
<div class="modal">
|
||||
<form method="post" onsubmit="return validateDeviceForm(this);">
|
||||
<div class="modal-header">
|
||||
<h3>Neues Gerät hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_device" value="1">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Sensor01">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="rpi_ip" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>MAC-Adresse</label>
|
||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||
</div>
|
||||
<div class="field"><label>Port</label>
|
||||
<input type="text" name="port" placeholder="z.B. 3">
|
||||
</div>
|
||||
<div class="field"><label>Switch (optional)</label>
|
||||
<select name="switch_hostname">
|
||||
<option value="">Kein Switch</option>
|
||||
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<!-- Modal: Bearbeiten -->
|
||||
<div class="modal-overlay" id="editDeviceModal">
|
||||
<div class="modal">
|
||||
<form method="post" onsubmit="return validateDeviceForm(this);">
|
||||
<input type="hidden" name="edit_device" value="1">
|
||||
<input type="hidden" name="old_mac" id="edit_old_mac">
|
||||
<div class="modal-header">
|
||||
<h3>Gerät bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="name" id="edit_name" required>
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="rpi_ip" id="edit_ip" required>
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>MAC-Adresse</label>
|
||||
<input type="text" name="mac" id="edit_mac" required>
|
||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||
</div>
|
||||
<div class="field"><label>Port</label>
|
||||
<input type="text" name="port" id="edit_port">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Switch ändern -->
|
||||
<div class="modal-overlay" id="switchModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="edit_device" value="1">
|
||||
<input type="hidden" name="old_mac" id="switch_mac">
|
||||
<div class="modal-header">
|
||||
<h3>Switch ändern</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Zugeordneter Switch</label>
|
||||
<select name="switch_hostname" id="switch_select">
|
||||
<option value="">Kein Switch</option>
|
||||
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function resetDeviceForm() {
|
||||
document.querySelector("#deviceModal form").reset();
|
||||
}
|
||||
function openEditDeviceModal(mac, name, ip, port) {
|
||||
document.getElementById("edit_old_mac").value = mac;
|
||||
document.getElementById("edit_name").value = name;
|
||||
document.getElementById("edit_ip").value = ip;
|
||||
document.getElementById("edit_mac").value = mac;
|
||||
document.getElementById("edit_port").value = port;
|
||||
PoeUI.openModal("editDeviceModal");
|
||||
}
|
||||
function openSwitchModal(mac, switchHostname) {
|
||||
document.getElementById("switch_mac").value = mac;
|
||||
document.getElementById("switch_select").value = switchHostname;
|
||||
PoeUI.openModal("switchModal");
|
||||
}
|
||||
|
||||
const ipPattern = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const macPattern = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/;
|
||||
|
||||
function validateIP(input) {
|
||||
const ok = ipPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
function validateMAC(input) {
|
||||
const ok = macPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
function validateDeviceForm(form) {
|
||||
const ipInput = form.querySelector("input[name='rpi_ip']");
|
||||
const macInput = form.querySelector("input[name='mac']");
|
||||
let valid = true;
|
||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||
if (macInput) valid = validateMAC(macInput) && valid;
|
||||
return valid;
|
||||
}
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.name === "rpi_ip") validateIP(e.target);
|
||||
if (e.target.name === "mac") validateMAC(e.target);
|
||||
});
|
||||
|
||||
function toggleDevice(mac, checkbox) {
|
||||
checkbox.disabled = true;
|
||||
fetch(`/devices/toggle/${mac}`, { method: "POST" })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
checkbox.disabled = false;
|
||||
if (data.success) {
|
||||
checkbox.checked = data.new_status === 1;
|
||||
showToast(data.msg, "success");
|
||||
} else {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
showToast(data.msg, "danger");
|
||||
}
|
||||
})
|
||||
.catch(() => { checkbox.disabled = false; checkbox.checked = !checkbox.checked; showToast("Fehler beim Umschalten.", "danger"); });
|
||||
}
|
||||
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "groups" %}
|
||||
{% block page_title %}Gruppen{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ groups|length }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neue Gruppe
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;">
|
||||
Admins dürfen immer alles. Über Gruppen lassen sich einzelne Verwaltungsrechte für
|
||||
Devices und Switches gezielt an normale Benutzer vergeben, ohne sie zu Admins zu machen.
|
||||
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich.
|
||||
</p>
|
||||
|
||||
{% if groups %}
|
||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||
{% for g in groups %}
|
||||
<div class="card card-pad">
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
|
||||
<div class="section-head">
|
||||
<div class="field" style="margin-bottom:0; max-width:280px; flex:1;">
|
||||
<label>Gruppenname</label>
|
||||
<input type="text" name="name" value="{{ g.name }}" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<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>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px; margin-top:8px;">
|
||||
{% for cat_key, cat in permission_catalog.items() %}
|
||||
<div>
|
||||
<div class="permission-group-title">{{ cat['label'] }}</div>
|
||||
<div class="check-list">
|
||||
{% for key, label in cat['items'].items() %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="permissions" value="{{ key }}" {% if key in g.permissions %}checked{% endif %}>
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
<div class="permission-group-title">Mitglieder</div>
|
||||
<div class="check-list" style="max-height:180px; overflow-y:auto; padding-right:4px;">
|
||||
{% for u in all_users %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
|
||||
{{ u['username'] }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Keine Nicht-Admin-Benutzer vorhanden.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:16px; padding-top:14px; border-top:1px solid var(--border-soft);">
|
||||
<span class="text-faint" style="font-size:12px;">
|
||||
{% if g.member_names %}Mitglieder: {{ g.member_names|join(', ') }}{% else %}Keine Mitglieder{% endif %}
|
||||
</span>
|
||||
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
|
||||
<input type="hidden" name="delete_group" value="{{ g.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Gruppe löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||
Noch keine Gruppen angelegt.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Modal: Neue Gruppe -->
|
||||
<div class="modal-overlay" id="addGroupModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
<div class="modal-header">
|
||||
<h3>Neue Gruppe</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_group" value="1">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
||||
<div class="field-hint">Rechte und Mitglieder werden danach auf der Gruppenkarte eingestellt.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,271 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "index" %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte im Bestand</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<span class="timer-pill"><span class="dot"></span><span id="dashboard-timer">Nächste Prüfung in --s</span></span>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Online</div>
|
||||
<div class="stat-value online">{{ stats.online }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Offline</div>
|
||||
<div class="stat-value offline">{{ stats.offline }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Deaktiviert</div>
|
||||
<div class="stat-value disabled">{{ stats.disabled }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Gesamt</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not current_user.is_authenticated %}
|
||||
{# ============================================================ #}
|
||||
{# Öffentliche, schreibgeschützte Kurzübersicht (kein Login) #}
|
||||
{# ============================================================ #}
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Hostname</th><th>IP-Adresse</th><th>Status</th><th>Letzte Prüfung</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
{% set st = status.get(d['mac'], 'unbekannt') %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ d['name'] }}</td>
|
||||
<td class="mono">{{ d['rpi_ip'] }}</td>
|
||||
<td>
|
||||
{% if d['is_active'] == 0 %}
|
||||
<span class="pill disabled">Deaktiviert</span>
|
||||
{% else %}
|
||||
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-dim">{{ last_checked.get(d['mac']) or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="4">Noch keine Geräte vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-faint mt-3" style="font-size:12px;">
|
||||
Nur Lese-Ansicht. <a href="{{ url_for('login') }}" style="color:var(--accent-strong); font-weight:600;">Anmelden</a>,
|
||||
um Geräte zu verwalten oder einen PoE-Neustart auszulösen.
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
{# ============================================================ #}
|
||||
{# Vollständiges Dashboard für eingeloggte Benutzer #}
|
||||
{# ============================================================ #}
|
||||
{% if devices %}
|
||||
<div class="device-grid">
|
||||
{% for d in devices %}
|
||||
{% set st = status.get(d['mac'], 'unbekannt') %}
|
||||
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %}"
|
||||
data-status="{{ 'offline' if d['is_active'] == 0 else st }}"
|
||||
data-mac="{{ d['mac'] }}"
|
||||
data-name="{{ d['name'] }}"
|
||||
data-ip="{{ d['rpi_ip'] }}"
|
||||
data-switch="{{ d['switch_hostname'] or '-' }}"
|
||||
data-port="{{ d['port'] or '-' }}"
|
||||
data-active="{{ d['is_active'] }}"
|
||||
data-checked="{{ last_checked.get(d['mac']) or '-' }}"
|
||||
{% if last_seen.get(d['mac']) %}title="{{ last_seen[d['mac']] }}"{% endif %}>
|
||||
<div class="dc-top">
|
||||
<div class="dc-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
|
||||
</div>
|
||||
{% if d['is_active'] == 0 %}
|
||||
<span class="pill disabled">Aus</span>
|
||||
{% else %}
|
||||
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dc-name">{{ d['name'] }}</div>
|
||||
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||
Noch keine Geräte angelegt. Füge welche unter <a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Devices</a> hinzu.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Device detail modal -->
|
||||
<div class="modal-overlay" id="deviceModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="deviceModalTitle">Gerät</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="detail-list">
|
||||
<div class="detail-row"><span class="k">IP-Adresse</span><span class="v mono" id="deviceIp">-</span></div>
|
||||
<div class="detail-row"><span class="k">Switch</span><span class="v" id="deviceSwitch">-</span></div>
|
||||
<div class="detail-row"><span class="k">Port</span><span class="v" id="devicePort">-</span></div>
|
||||
<div class="detail-row"><span class="k">Status</span><span class="v" id="deviceStatus">-</span></div>
|
||||
<div class="detail-row"><span class="k">Letzte Prüfung</span><span class="v" id="deviceChecked">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||
{% if current_user.has_permission('devices.restart') %}
|
||||
<button type="button" class="btn btn-primary" id="restartButton">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
Neustarten
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has_permission('devices.restart') %}
|
||||
<!-- Restart confirm modal -->
|
||||
<div class="modal-overlay" id="restartModal">
|
||||
<div class="modal" style="max-width:400px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="restartTitle">Neustart</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-dim">Der Neustart erfolgt durch einen kurzen PoE-Reset. Das Gerät wird für wenige Sekunden vom Netzwerk getrennt.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmRestart" style="background:var(--warning); color:#241a00;">Neustarten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const intervalMinutes = {{ interval | int }};
|
||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||
const isAuthenticated = {{ "true" if current_user.is_authenticated else "false" }};
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
function updateTimer() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastUpdateTime;
|
||||
const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds);
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
const el = document.getElementById("dashboard-timer");
|
||||
if (el) el.innerText = `Nächste Prüfung in ${remainingSec}s`;
|
||||
if (remainingSec <= 1) window.location.reload();
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Öffentliche Ansicht: einfacher Reload-Timer, kein Zugriff auf /get_log nötig.
|
||||
setInterval(updateTimer, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedCard = null, selectedMac = null, selectedName = null;
|
||||
|
||||
function parseLogTimestamp(ts) {
|
||||
const parts = ts.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
||||
if (!parts) return Date.now();
|
||||
const [, year, month, day, hour, minute, second] = parts.map(Number);
|
||||
return new Date(year, month - 1, day, hour, minute, second).getTime();
|
||||
}
|
||||
|
||||
function fetchLastLog() {
|
||||
fetch("{{ url_for('get_log') }}")
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
const lines = data.split("\n");
|
||||
let lastSepIndex = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("----")) { lastSepIndex = i; break; }
|
||||
}
|
||||
if (lastSepIndex >= 0 && lastSepIndex + 1 < lines.length) {
|
||||
const match = lines[lastSepIndex + 1].match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
|
||||
if (match) lastUpdateTime = parseLogTimestamp(match[1]);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".device-card").forEach(card => {
|
||||
const mac = card.dataset.mac;
|
||||
const restartKey = "restart_" + mac;
|
||||
if (sessionStorage.getItem(restartKey)) {
|
||||
const pill = card.querySelector(".pill");
|
||||
if (pill && pill.innerText.trim().toLowerCase() === "online") {
|
||||
sessionStorage.removeItem(restartKey);
|
||||
} else if (pill) {
|
||||
pill.innerHTML = '<span class="spinner"></span> Restarting…';
|
||||
}
|
||||
}
|
||||
card.addEventListener("click", function () {
|
||||
if (this.dataset.active == "0") return;
|
||||
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
|
||||
selectedCard = this;
|
||||
selectedMac = this.dataset.mac;
|
||||
selectedName = this.dataset.name;
|
||||
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
|
||||
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
|
||||
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
|
||||
document.getElementById("devicePort").innerText = this.dataset.port || "-";
|
||||
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
|
||||
const pill = this.querySelector(".pill");
|
||||
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
|
||||
PoeUI.openModal("deviceModal");
|
||||
});
|
||||
});
|
||||
|
||||
const restartButton = document.getElementById("restartButton");
|
||||
if (restartButton) {
|
||||
restartButton.addEventListener("click", function () {
|
||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||
document.getElementById("restartTitle").innerText = selectedName + " neu starten?";
|
||||
PoeUI.openModal("restartModal");
|
||||
});
|
||||
}
|
||||
|
||||
const confirmRestart = document.getElementById("confirmRestart");
|
||||
if (confirmRestart) {
|
||||
confirmRestart.addEventListener("click", function () {
|
||||
const button = this;
|
||||
button.disabled = true;
|
||||
fetch("/restart/" + encodeURIComponent(selectedMac), { method: "POST" })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
button.disabled = false;
|
||||
if (!data.success) {
|
||||
showToast(data.message || "Neustart konnte nicht gestartet werden.", "danger");
|
||||
return;
|
||||
}
|
||||
PoeUI.closeModal(document.getElementById("restartModal"));
|
||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||
sessionStorage.setItem("restart_" + selectedMac, "1");
|
||||
const pill = selectedCard.querySelector(".pill");
|
||||
if (pill) pill.innerHTML = '<span class="spinner"></span> Restarting…';
|
||||
showToast(`Neustart von ${data.device} gestartet.`, "success");
|
||||
})
|
||||
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(updateTimer, 1000);
|
||||
fetchLastLog();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login · PoE Manager</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
|
||||
</div>
|
||||
<h1>PoE Manager</h1>
|
||||
<p class="login-sub">Melde dich an, um fortzufahren</p>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="field" style="margin-bottom:18px;">
|
||||
{% for message in messages %}
|
||||
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Live-Log{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ log_name or "kein Logfile" }}</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<span class="timer-pill"><span class="dot"></span>Update in <span id="timer">--</span>s</span>
|
||||
<button id="refresh-btn" class="btn btn-secondary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
Aktualisieren
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="log-shell">
|
||||
<div class="log-toolbar">
|
||||
<div class="log-dots"><span></span><span></span><span></span></div>
|
||||
<span class="text-faint mono" style="font-size:11.5px;">{{ log_name or "" }}</span>
|
||||
</div>
|
||||
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function parseLogTimestamp(ts) {
|
||||
const parts = ts.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
||||
if (!parts) return Date.now();
|
||||
const [, year, month, day, hour, minute, second] = parts.map(Number);
|
||||
return new Date(year, month - 1, day, hour, minute, second).getTime();
|
||||
}
|
||||
|
||||
function colorizeLine(line) {
|
||||
let cls = "";
|
||||
if (line.includes(" ist erreichbar!")) cls = "online";
|
||||
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
|
||||
else if (line.startsWith("----")) cls = "sep";
|
||||
else if (line.toLowerCase().includes("manuell")) cls = "manual";
|
||||
const span = document.createElement("span");
|
||||
span.className = "log-line" + (cls ? " " + cls : "");
|
||||
span.textContent = line;
|
||||
return span;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const intervalMinutes = {{ interval | int }};
|
||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
function renderLog(data) {
|
||||
const box = document.getElementById("log-box");
|
||||
box.innerHTML = "";
|
||||
const lines = data.split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
box.appendChild(colorizeLine(line));
|
||||
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
|
||||
});
|
||||
box.scrollTop = box.scrollHeight;
|
||||
|
||||
let lastSep = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("----")) { lastSep = i; break; }
|
||||
}
|
||||
if (lastSep >= 0 && lastSep + 1 < lines.length) {
|
||||
const match = lines[lastSep + 1].match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
|
||||
lastUpdateTime = match ? parseLogTimestamp(match[1]) : Date.now();
|
||||
} else {
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function fetchLog() {
|
||||
fetch("{{ url_for('get_log') }}")
|
||||
.then(r => r.text())
|
||||
.then(renderLog)
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastUpdateTime;
|
||||
const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds);
|
||||
document.getElementById("timer").innerText = Math.ceil(remainingMs / 1000);
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
|
||||
fetchLog();
|
||||
setInterval(fetchLog, intervalMilliseconds);
|
||||
setInterval(updateTimer, 1000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings" %}
|
||||
{% block page_title %}Settings{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Prüfintervall für das Monitoring</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card-pad" style="max-width:420px;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Prüfintervall</h2>
|
||||
<div class="hint">Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="interval">Intervall (Minuten)</label>
|
||||
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
|
||||
<div class="field-hint">Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<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>
|
||||
Speichern & Service neustarten
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,334 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "switches" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/xterm.css') }}">
|
||||
{% endblock %}
|
||||
{% set can_create = current_user.has_permission('switches.create') %}
|
||||
{% set can_edit = current_user.has_permission('switches.edit') %}
|
||||
{% set can_delete = current_user.has_permission('switches.delete') %}
|
||||
{% block page_title %}Switches{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
{% if can_create %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neuer Switch
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="switchSearch" placeholder="Switches durchsuchen…" oninput="filterTable('switchSearch','switchesTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="switchesTable">
|
||||
<thead>
|
||||
<tr><th>Hostname</th><th>IP-Adresse</th><th>Username</th><th style="width:1%;">Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in switches %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ s['hostname'] }}</td>
|
||||
<td class="mono">{{ s['ip'] }}</td>
|
||||
<td>{{ s['username'] }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten" data-open-modal="editSwitchModal{{ loop.index }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form method="post" action="{{ url_for('delete_switch', hostname=s['hostname']) }}" data-confirm="Willst du den Switch „{{ s['hostname'] }}“ wirklich löschen?">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
|
||||
<div class="modal">
|
||||
<form method="post" onsubmit="return validateSwitchForm(this, '{{ loop.index }}');">
|
||||
<input type="hidden" name="edit_switch" value="1">
|
||||
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
|
||||
<div class="modal-header">
|
||||
<h3>Switch bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="hostname" value="{{ s['hostname'] }}" required>
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" value="{{ s['username'] }}" required>
|
||||
</div>
|
||||
<div class="field"><label>Neues Passwort</label>
|
||||
<input type="password" id="password_edit_{{ loop.index }}" name="password" placeholder="Nur bei Änderung ausfüllen">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_edit_{{ loop.index }}" name="password_confirm" placeholder="Bestätigen">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||
Verbindung testen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="4">Noch keine Switche vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create %}
|
||||
<!-- Modal: Neuer Switch -->
|
||||
<div class="modal-overlay" id="addSwitchModal">
|
||||
<div class="modal">
|
||||
<form method="post" onsubmit="return validateSwitchForm(this, 'add');">
|
||||
<div class="modal-header">
|
||||
<h3>Neuen Switch hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_switch" value="1">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="hostname" required placeholder="z.B. Switch01">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" required placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" id="password_add" name="password" required>
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_add" name="password_confirm" required>
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||
Verbindung testen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_create or can_edit %}
|
||||
<!-- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys
|
||||
und Prüfen der Zugangsdaten, bevor der Switch gespeichert wird) -->
|
||||
<div class="modal-overlay" id="terminalModal">
|
||||
<div class="modal" style="max-width:720px;">
|
||||
<div class="modal-header">
|
||||
<h3>SSH-Verbindungstest</h3>
|
||||
<button type="button" class="modal-close" data-close-modal onclick="closeTerminal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:0;">
|
||||
<div class="term-toolbar">
|
||||
<div class="flex gap-2" style="align-items:center;">
|
||||
<span id="termStatus" class="pill unknown">Bereit</span>
|
||||
<span id="termTarget" class="text-faint mono" style="font-size:12px;"></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="termPastePassword">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
Passwort einfügen
|
||||
</button>
|
||||
</div>
|
||||
<div id="terminal" class="xterm-container"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<p class="text-faint" style="font-size:11.5px; margin-right:auto;">
|
||||
Erste Verbindung? Bestätige den Host-Key mit „yes“, gib danach das Passwort ein — direkt hier im Terminal.
|
||||
</p>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
|
||||
<script>
|
||||
// -------------------------------------------------------------------------
|
||||
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
|
||||
// -------------------------------------------------------------------------
|
||||
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
|
||||
|
||||
function ensureTerminal() {
|
||||
if (term) return;
|
||||
term = new Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace",
|
||||
cursorBlink: true,
|
||||
theme: { background: "#0a0c10", foreground: "#c7ccd6" },
|
||||
});
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById("terminal"));
|
||||
fitAddon.fit();
|
||||
term.onData((data) => {
|
||||
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
|
||||
termSocket.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
|
||||
termSocket.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", () => fitAddon && fitAddon.fit());
|
||||
}
|
||||
|
||||
function setTermStatus(text, cls) {
|
||||
const el = document.getElementById("termStatus");
|
||||
el.className = "pill " + cls;
|
||||
el.innerText = text;
|
||||
}
|
||||
|
||||
function openTerminal(form) {
|
||||
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
|
||||
const username = (form.querySelector("input[name='username']") || {}).value?.trim();
|
||||
activePasswordInput = form.querySelector("input[name='password']");
|
||||
|
||||
if (!host || !username) {
|
||||
showToast("Bitte IP-Adresse und Username ausfüllen, bevor du die Verbindung testest.", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
PoeUI.openModal("terminalModal");
|
||||
ensureTerminal();
|
||||
term.reset();
|
||||
document.getElementById("termTarget").innerText = `${username}@${host}`;
|
||||
setTermStatus("Verbinde…", "unknown");
|
||||
setTimeout(() => fitAddon && fitAddon.fit(), 60);
|
||||
|
||||
if (termSocket) { try { termSocket.close(); } catch (e) {} }
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
termSocket = new WebSocket(`${proto}//${location.host}/ws/ssh_terminal`);
|
||||
|
||||
termSocket.onopen = () => {
|
||||
termSocket.send(JSON.stringify({ host, username, port: 22 }));
|
||||
setTermStatus("Verbunden", "online");
|
||||
};
|
||||
termSocket.onmessage = (event) => term.write(event.data);
|
||||
termSocket.onclose = () => setTermStatus("Getrennt", "offline");
|
||||
// Bewusst kein Toast bei "error": manche Browser melden beim Schließen
|
||||
// einer WebSocket-Verbindung ein error-Event, obwohl die Sitzung
|
||||
// inhaltlich erfolgreich war. Terminal-Inhalt + Status-Pill genügen als
|
||||
// Feedback — der Verbindungsstatus wird zuverlässig über onclose gepflegt.
|
||||
termSocket.onerror = () => setTermStatus("Fehler", "offline");
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
if (termSocket) {
|
||||
try { termSocket.close(); } catch (e) {}
|
||||
termSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
const termPasteBtn = document.getElementById("termPastePassword");
|
||||
if (termPasteBtn) {
|
||||
termPasteBtn.addEventListener("click", () => {
|
||||
if (!activePasswordInput || !activePasswordInput.value) {
|
||||
showToast("Kein Passwort im Formular eingetragen.", "danger");
|
||||
return;
|
||||
}
|
||||
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
|
||||
showToast("Keine aktive Terminal-Verbindung.", "danger");
|
||||
return;
|
||||
}
|
||||
termSocket.send(JSON.stringify({ type: "input", data: activePasswordInput.value + "\n" }));
|
||||
});
|
||||
}
|
||||
|
||||
// Verbindung auch beim Schließen per Klick auf Backdrop / Escape sauber trennen
|
||||
const terminalModalEl = document.getElementById("terminalModal");
|
||||
if (terminalModalEl) {
|
||||
terminalModalEl.addEventListener("click", (e) => {
|
||||
if (e.target.id === "terminalModal") closeTerminal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && terminalModalEl.classList.contains("open")) closeTerminal();
|
||||
});
|
||||
}
|
||||
|
||||
const ipPattern = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
|
||||
function validateIP(input) {
|
||||
const ok = ipPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function validatePassword(id, isEdit) {
|
||||
const pass = document.getElementById(isEdit ? `password_edit_${id}` : "password_add");
|
||||
const confirm = document.getElementById(isEdit ? `password_confirm_edit_${id}` : "password_confirm_add");
|
||||
if (!pass || !confirm) return true;
|
||||
if (pass.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
|
||||
confirm.classList.remove("is-invalid");
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateSwitchForm(form, id) {
|
||||
const ipInput = form.querySelector("input[name='ip']");
|
||||
let valid = true;
|
||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||
valid = validatePassword(id, id !== "add") && valid;
|
||||
return valid;
|
||||
}
|
||||
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.name === "ip") validateIP(e.target);
|
||||
if (e.target.id.startsWith("password_confirm")) {
|
||||
const id = e.target.id.replace("password_confirm_", "");
|
||||
const pass = document.getElementById("password_" + id);
|
||||
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "users" %}
|
||||
{% block page_title %}Users{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neuer Benutzer
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Username</th><th>Rolle</th><th>Gruppen</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ u['username'] }}</td>
|
||||
<td><span class="pill {{ 'admin' if u['is_admin'] else 'user' }}">{{ "Admin" if u['is_admin'] else "User" }}</span></td>
|
||||
<td class="text-dim">{{ u['group_names'] or '—' }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="btn btn-sm btn-secondary" onclick="openRoleModal({{ u['id'] }}, '{{ u['username'] }}', {{ u['is_admin'] }})">Rolle</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="openPasswordModal({{ u['id'] }}, '{{ u['username'] }}')">Passwort</button>
|
||||
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
|
||||
<button type="submit" name="delete_user" value="{{ u['id'] }}" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="4">Noch keine Benutzer vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Neuer Benutzer -->
|
||||
<div class="modal-overlay" id="userModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="userForm">
|
||||
<div class="modal-header">
|
||||
<h3>Neuen Benutzer anlegen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Username</label><input type="text" name="username" required></div>
|
||||
<div class="field"><label>Passwort</label><input type="password" name="password" required></div>
|
||||
<div class="field"><label>Rolle</label>
|
||||
<select name="is_admin"><option value="0">User</option><option value="1">Admin</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="add_user" value="1" class="btn btn-primary">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Rolle ändern -->
|
||||
<div class="modal-overlay" id="roleModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="roleForm">
|
||||
<input type="hidden" name="user_id" id="role_user_id">
|
||||
<div class="modal-header">
|
||||
<h3>Rolle ändern</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Username</label><input type="text" name="username" id="role_username" required></div>
|
||||
<div class="field"><label>Rolle</label>
|
||||
<select name="is_admin" id="role_is_admin"><option value="0">User</option><option value="1">Admin</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="change_role" value="1" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Passwort ändern -->
|
||||
<div class="modal-overlay" id="passwordModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="passwordForm">
|
||||
<input type="hidden" name="user_id" id="password_user_id">
|
||||
<div class="modal-header">
|
||||
<h3>Passwort ändern</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Username</label><input type="text" name="username" id="password_username" readonly></div>
|
||||
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" required></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="change_password" value="1" class="btn btn-primary">Ändern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function openRoleModal(userId, username, isAdmin) {
|
||||
document.getElementById("role_user_id").value = userId;
|
||||
document.getElementById("role_username").value = username;
|
||||
document.getElementById("role_is_admin").value = isAdmin;
|
||||
PoeUI.openModal("roleModal");
|
||||
}
|
||||
function openPasswordModal(userId, username) {
|
||||
document.getElementById("password_user_id").value = userId;
|
||||
document.getElementById("password_username").value = username;
|
||||
document.querySelector("#passwordForm input[name='new_password']").value = "";
|
||||
PoeUI.openModal("passwordModal");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user