Initial release — Salus by Stranto v1.6.1.0

FastAPI/Jinja2 web app for viewing and rebooting TP-Link Omada APs
across all sites. Authentik OIDC auth, SQLite audit log, Docker deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 14:36:02 +02:00
commit 284924e86d
17 changed files with 1646 additions and 0 deletions

132
app/templates/audit.html Normal file
View File

@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Audit Log Salus by Stranto{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Audit Log</h1>
<p class="text-sm text-gray-500 mt-0.5">All reboot actions · {{ logs|length }} record{{ 's' if logs|length != 1 }}</p>
</div>
<a href="/audit/export"
class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-500 border border-blue-500 text-white transition-colors self-start sm:self-auto">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Export CSV
</a>
</div>
<!-- Filters -->
<form method="get" class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<input type="text" name="username" value="{{ filter_username }}"
placeholder="Filter by username…"
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0"/>
</svg>
<input type="text" name="ap_name" value="{{ filter_ap }}"
placeholder="Filter by AP name…"
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div class="flex gap-2">
<button type="submit"
class="px-4 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 transition-colors">
Filter
</button>
{% if filter_username or filter_ap %}
<a href="/audit"
class="px-4 py-2 text-sm rounded-lg bg-gray-50 hover:bg-gray-100 border border-gray-200 text-gray-500 transition-colors">
Clear
</a>
{% endif %}
</div>
</form>
{% if logs %}
<!-- Table -->
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Timestamp (UTC)</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">User</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">AP Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Result</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{% for log in logs %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3 text-gray-500 font-mono text-xs whitespace-nowrap">
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td class="px-4 py-3">
<div class="text-gray-900 text-sm font-medium">{{ log.username }}</div>
<div class="text-gray-400 text-xs">{{ log.user_email }}</div>
</td>
<td class="px-4 py-3 text-gray-900 font-medium">{{ log.ap_name }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ log.ap_mac }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ log.ap_ip or '—' }}</td>
<td class="px-4 py-3">
{% if log.result == 'success' %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
Success
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
Error
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-400 text-xs max-w-xs truncate" title="{{ log.error_message or '' }}">
{{ log.error_message or '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-lg font-medium">No log entries found</p>
{% if filter_username or filter_ap %}
<p class="text-sm mt-1">Try adjusting your filters.</p>
{% else %}
<p class="text-sm mt-1">Reboot actions will appear here.</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}