its been a while

This commit is contained in:
boris committed 2026-04-16 17:12:29 +01:00
1 parent ea7470231f
commit 34f13c65e1
47 files changed
+5680 -339

No files matched your search

@@ -0,0 +1,142 @@
{% load tz %}
<div class="space-y-4">
<details class="rounded-xl border border-gray-200 bg-gray-50" {% if panel.filters_expanded %}open{% endif %}>
<summary class="flex cursor-pointer items-center justify-between px-4 py-3 text-xs font-semibold uppercase tracking-wide text-gray-600">
<span>Filters</span>
<span class="rounded-full bg-white px-2 py-0.5 text-[11px] font-semibold text-gray-700">Matches: {{ panel.filtered_count }}</span>
</summary>
<div class="border-t border-gray-200 bg-white p-4">
<form method="get" action="{{ panel.panel_action }}" class="js-audit-panel-form grid gap-3 md:grid-cols-4">
<input type="hidden" name="line_limit" value="{{ panel.filter_values.line_limit }}" />
<input type="hidden" name="login_outcome" value="{{ panel.filter_values.login_outcome }}" />
<input type="hidden" name="http_status" value="{{ panel.filter_values.http_status }}" />
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source type</label>
<select name="source_kind" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.source_kinds %}
{% if option %}
<option value="{{ option }}" {% if panel.filter_values.source_kind == option %}selected{% endif %}>{{ option }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source</label>
<select name="source_name" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.source_names %}
{% if option %}
<option value="{{ option }}" {% if panel.filter_values.source_name == option %}selected{% endif %}>{{ option }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Category</label>
<select name="category" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.categories %}
<option value="{{ option }}" {% if panel.filter_values.category == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Event type</label>
<select name="event_type" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.event_types %}
<option value="{{ option }}" {% if panel.filter_values.event_type == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Priority</label>
<select name="priority" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
<option value="">All</option>
{% for option in panel.filter_options.priorities %}
{% if option %}
<option value="{{ option }}" {% if panel.filter_values.priority == option %}selected{% endif %}>{{ option }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Username</label>
<input name="username" type="text" value="{{ panel.filter_values.username }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source IP</label>
<input name="source_ip" type="text" value="{{ panel.filter_values.source_ip }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Search</label>
<input name="q" type="text" value="{{ panel.filter_values.q }}" placeholder="message, principal, source" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Since</label>
<input name="since" type="datetime-local" value="{{ panel.filter_values.since }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Until</label>
<input name="until" type="datetime-local" value="{{ panel.filter_values.until }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
</div>
<div class="md:col-span-4 flex items-center justify-end gap-2">
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Apply</button>
<a href="{{ panel.reset_href }}" class="js-panel-link inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">Reset</a>
</div>
</form>
</div>
</details>
{% if panel.logs %}
<div class="overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-xs">
<thead class="bg-gray-50 text-left uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Time</th>
<th class="px-3 py-2 font-semibold">Source</th>
<th class="px-3 py-2 font-semibold">Log line</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for log in panel.logs %}
<tr class="align-top">
<td class="whitespace-nowrap px-3 py-2 text-gray-500">{{ log.event_at|utc|date:"Y-m-d H:i:s" }} UTC</td>
<td class="px-3 py-2">
<div class="font-medium text-gray-900">{{ log.compact_source }}</div>
<div class="text-gray-500">{{ log.event_type }} • {{ log.category }}{% if log.source_kind %} • {{ log.source_kind }}{% endif %}</div>
</td>
<td class="px-3 py-2">
<div class="font-mono text-[11px] text-gray-900">{{ log.compact_line }}</div>
{% if log.username or log.source_ip or log.session_id %}
<div class="mt-1 text-gray-500">
{% if log.username %}user={{ log.username }}{% endif %}
{% if log.source_ip %}{% if log.username %} • {% endif %}ip={{ log.source_ip }}{% endif %}
{% if log.session_id %}{% if log.username or log.source_ip %} • {% endif %}session={{ log.session_id }}{% endif %}
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex items-center justify-between text-xs text-gray-600">
<span>Page {{ panel.page_obj.number }} of {{ panel.page_obj.paginator.num_pages }}</span>
<div class="flex items-center gap-2">
{% if panel.page_obj.has_previous %}
<a href="{{ panel.panel_action }}?{% if panel.query_without_page %}{{ panel.query_without_page }}&{% endif %}page={{ panel.page_obj.previous_page_number }}" class="js-panel-link rounded border border-gray-200 px-2.5 py-1 hover:bg-gray-50">Previous</a>
{% endif %}
{% if panel.page_obj.has_next %}
<a href="{{ panel.panel_action }}?{% if panel.query_without_page %}{{ panel.query_without_page }}&{% endif %}page={{ panel.page_obj.next_page_number }}" class="js-panel-link rounded border border-gray-200 px-2.5 py-1 hover:bg-gray-50">Next</a>
{% endif %}
</div>
</div>
{% else %}
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<h4 class="text-sm font-semibold text-gray-900">No logs for this panel filter</h4>
<p class="mt-1 text-xs text-gray-500">Adjust source/file/service filters or broaden the timeframe.</p>
</div>
{% endif %}
</div>
+32 -18
View File
@@ -85,18 +85,22 @@
</div>
<nav class="mt-4 flex flex-wrap gap-2 border-b border-gray-200 pb-3 text-sm font-medium text-gray-500">
<a
href="{% url 'servers:detail' server.id %}"
class="{% if active_tab == 'details' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Details
</a>
<a
href="{% url 'servers:audit' server.id %}"
class="{% if active_tab == 'audit' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Audit
</a>
{% if can_users %}
<a
href="{% url 'servers:detail' server.id %}"
class="{% if active_tab == 'details' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Details
</a>
{% endif %}
{% if can_logs %}
<a
href="{% url 'servers:audit' server.id %}"
class="{% if active_tab == 'audit' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Audit
</a>
{% endif %}
{% if can_shell %}
<a
href="{% url 'servers:shell' server.id %}"
@@ -105,10 +109,20 @@
Shell
</a>
{% endif %}
<a
href="{% url 'servers:settings' server.id %}"
class="{% if active_tab == 'settings' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Settings
</a>
{% if can_users %}
<a
href="{% url 'servers:settings' server.id %}"
class="{% if active_tab == 'settings' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Settings
</a>
{% endif %}
{% if is_server_admin %}
<a
href="{% url 'servers:server_admin' server.id %}"
class="{% if active_tab == 'admin' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Admin
</a>
{% endif %}
</nav>
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}Admin • Keywarden{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Admin</h1>
<p class="mt-1 text-sm text-gray-500">Global administrative controls for Keywarden.</p>
</div>
</div>
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Users</p>
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.users }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Servers</p>
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.servers }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Pending Requests</p>
<p class="mt-2 text-2xl font-semibold text-amber-700">{{ summary.pending_requests }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Active Grants</p>
<p class="mt-2 text-2xl font-semibold text-emerald-700">{{ summary.active_grants }}</p>
</article>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Access request queue</h2>
<p class="mt-1 text-sm text-gray-500">Review and decide pending access requests.</p>
</div>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{{ pending_access_requests|length }} pending
</span>
</div>
{% if pending_access_requests %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Requester</th>
<th class="px-3 py-2 font-semibold">Server</th>
<th class="px-3 py-2 font-semibold">Requested scopes</th>
<th class="px-3 py-2 font-semibold">Requested at</th>
<th class="px-3 py-2 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for req in pending_access_requests %}
<tr>
<td class="px-3 py-2">{{ req.requester.username }}</td>
<td class="px-3 py-2">
<a href="{% url 'servers:server_admin' req.server.id %}" class="font-medium text-blue-700 hover:underline">
{{ req.server.display_name }}
</a>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-2">
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
</form>
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="deny">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No pending access requests.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Global admin utilities</h2>
<p class="mt-1 text-sm text-gray-500">Central administrative tooling for Keywarden operations.</p>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{% for utility in utilities %}
<a href="{{ utility.url }}" class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-blue-200 hover:bg-blue-50">
<h3 class="text-sm font-semibold text-gray-900">{{ utility.title }}</h3>
<p class="mt-1 text-xs text-gray-600">{{ utility.description }}</p>
</a>
{% endfor %}
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Server admin pages</h2>
<p class="mt-1 text-sm text-gray-500">Open per-server administrative controls and user/account views.</p>
</div>
{% if servers %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Server</th>
<th class="px-3 py-2 font-semibold">Hostname / IP</th>
<th class="px-3 py-2 font-semibold text-right">Admin</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for server in servers %}
<tr>
<td class="px-3 py-2">{{ server.display_name }}</td>
<td class="px-3 py-2">{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}</td>
<td class="px-3 py-2 text-right">
<a href="{% url 'servers:server_admin' server.id %}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Open admin
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No servers available.</p>
{% endif %}
</section>
</div>
{% endblock %}
+581 -25
View File
@@ -1,46 +1,602 @@
{% extends "base.html" %}
{% load static tz %}
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %}
{% block extra_head %}
<style>
.ak-chart-card {
border-color: #1f2937;
background: radial-gradient(120% 140% at 0% 0%, #1e293b 0%, #111827 50%, #0b1220 100%);
color: #e5e7eb;
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.35);
}
.ak-chart-title {
color: #f3f4f6;
font-weight: 700;
letter-spacing: 0.01em;
}
.ak-chart-subtitle {
color: #94a3b8;
}
.ak-chart-badge {
border: 1px solid rgba(100, 116, 139, 0.45);
background: rgba(15, 23, 42, 0.65);
color: #e2e8f0;
}
.ak-chart-canvas-wrap {
border: 1px solid rgba(71, 85, 105, 0.35);
background: rgba(2, 6, 23, 0.32);
border-radius: 14px;
padding: 0.6rem 0.75rem;
}
.ak-chart-empty {
color: #94a3b8;
}
</style>
{% endblock %}
{% block content %}
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2>
<p class="mt-1 text-sm text-gray-500">Track certificate issuance and access events.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Logs will appear here once collection is enabled for this server.</p>
<section class="rounded-2xl border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex flex-wrap items-center gap-2 text-sm font-medium text-gray-500">
<button
type="button"
class="js-audit-subpage rounded-full px-4 py-1.5 text-xs font-semibold {% if audit_subpage == 'charts' %}bg-blue-50 text-blue-700 ring-1 ring-blue-100{% else %}bg-gray-100 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
data-audit-subpage="charts"
>
Charts
</button>
<button
type="button"
class="js-audit-subpage rounded-full px-4 py-1.5 text-xs font-semibold {% if audit_subpage == 'logs' %}bg-blue-50 text-blue-700 ring-1 ring-blue-100{% else %}bg-gray-100 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
data-audit-subpage="logs"
>
Logs
</button>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2>
<p class="mt-1 text-sm text-gray-500">Monitor CPU, memory, and session activity.</p>
<h2 class="text-lg font-semibold text-gray-900">Audit workspace</h2>
<p class="mt-1 text-sm text-gray-500">Open multiple filtered panels side by side for service/file comparisons.</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold">
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-blue-700">Total: {{ audit_summary.total_count }}</span>
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700">24h: {{ audit_summary.recent_24h_count }}</span>
<div id="js-audit-log-controls" class="flex flex-wrap items-center gap-2 {% if audit_subpage != 'logs' %}hidden{% endif %}">
<label for="js-audit-line-limit" class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-gray-700">
<span>Lines</span>
<select id="js-audit-line-limit" class="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs font-semibold text-gray-700">
{% for option in audit_line_limit_options %}
<option value="{{ option }}" {% if option == audit_line_limit %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<button type="button" id="js-add-audit-panel" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
New panel
</button>
</div>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12h18M7 8v8M17 8v8" />
</svg>
<dl class="mt-4 grid gap-4 text-xs text-gray-600 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt class="font-semibold uppercase tracking-wide text-gray-500">Last event</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_event_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_event_at %} UTC{% endif %}</dd>
</div>
<p class="mt-3 text-sm text-gray-600">Metrics will appear here once collection is enabled for this server.</p>
<div>
<dt class="font-semibold uppercase tracking-wide text-gray-500">Last ingest</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_ingest_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_ingest_at %} UTC{% endif %}</dd>
</div>
<div class="sm:col-span-2">
<dt class="font-semibold uppercase tracking-wide text-gray-500">Top categories</dt>
<dd class="mt-1">
{% if audit_summary.top_categories %}
{% for item in audit_summary.top_categories %}
<span class="mr-2 inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-700">{{ item.category }}: {{ item.total }}</span>
{% endfor %}
{% else %}
—
{% endif %}
</dd>
</div>
</dl>
</section>
<section id="js-audit-subpage-charts" class="space-y-4 {% if audit_subpage != 'charts' %}hidden{% endif %}">
{% with login=audit_charts.login_attempts %}
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="ak-chart-title text-sm">Login Attempts vs Failures</h3>
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to open logs with matching filters.</p>
</div>
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
Failure rate: {{ login.failure_rate_pct }}%
</span>
</div>
<div class="ak-chart-canvas-wrap mt-4 h-64">
<canvas id="js-login-chart"></canvas>
</div>
<p id="js-login-chart-empty" class="ak-chart-empty mt-2 text-xs {% if login.chart.labels %}hidden{% endif %}">No login events detected yet.</p>
</article>
{% endwith %}
<div class="grid gap-4 lg:grid-cols-2">
{% with http_codes=audit_charts.http_status_codes %}
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="ak-chart-title text-sm">HTTP Status Codes (Nginx)</h3>
<p class="ak-chart-subtitle mt-1 text-xs">Excludes implicit `1xx` and `200`; click a bar to filter logs.</p>
</div>
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
Visible HTTP events: {{ http_codes.total }}
</span>
</div>
<div class="ak-chart-canvas-wrap mt-4 h-64">
<canvas id="js-http-status-chart"></canvas>
</div>
{% if http_codes.chart.labels %}
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 hidden text-xs"></p>
{% elif http_codes.implicit_total %}
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 text-xs">Only implicit `1xx`/`200` responses were observed ({{ http_codes.implicit_total }} entries), so this chart is intentionally empty.</p>
{% else %}
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 text-xs">No HTTP access events with status codes yet.</p>
{% endif %}
</article>
{% endwith %}
{% with event_types=audit_charts.security_event_types %}
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="ak-chart-title text-sm">Security Event Types</h3>
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to open logs filtered to that event type.</p>
</div>
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
Events: {{ event_types.total }}
</span>
</div>
<div class="ak-chart-canvas-wrap mt-4 h-64">
<canvas id="js-security-events-chart"></canvas>
</div>
<p id="js-security-events-chart-empty" class="ak-chart-empty mt-2 text-xs {% if event_types.chart.labels %}hidden{% endif %}">No security event-type distribution available yet.</p>
</article>
{% endwith %}
</div>
{% with failed_ips=audit_charts.failed_source_ips %}
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="ak-chart-title text-sm">Failed Login Source IPs</h3>
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to inspect failed logins from that source IP.</p>
</div>
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
Failed events: {{ failed_ips.total }}
</span>
</div>
<div class="ak-chart-canvas-wrap mt-4 h-64">
<canvas id="js-failed-ips-chart"></canvas>
</div>
<p id="js-failed-ips-chart-empty" class="ak-chart-empty mt-2 text-xs {% if failed_ips.chart.labels %}hidden{% endif %}">No failed-login IP activity detected.</p>
</article>
{% endwith %}
</section>
<section id="js-audit-subpage-logs" class="{% if audit_subpage != 'logs' %}hidden{% endif %}">
<div id="js-audit-panel-grid" class="grid gap-4">
<article class="rounded-2xl border border-gray-200 bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<h3 class="text-sm font-semibold text-gray-900">Panel 1</h3>
<div class="flex items-center gap-2">
<button type="button" class="js-duplicate-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50">Duplicate</button>
<button type="button" class="js-close-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50" disabled>Close</button>
</div>
</div>
<div class="js-panel-content p-4">
{% include "servers/_audit_panel.html" with panel=initial_panel server=server %}
</div>
</article>
</div>
</section>
</div>
{{ audit_charts.login_attempts.chart|json_script:"js-login-chart-data" }}
{{ audit_charts.http_status_codes.chart|json_script:"js-http-status-chart-data" }}
{{ audit_charts.security_event_types.chart|json_script:"js-security-events-chart-data" }}
{{ audit_charts.failed_source_ips.chart|json_script:"js-failed-ips-chart-data" }}
<script src="{% static 'unfold/js/chart/chart.js' %}"></script>
<script>
(function () {
var subpageButtons = document.querySelectorAll(".js-audit-subpage");
var chartsSection = document.getElementById("js-audit-subpage-charts");
var logsSection = document.getElementById("js-audit-subpage-logs");
var logControls = document.getElementById("js-audit-log-controls");
var panelCounter = 1;
var grid = document.getElementById("js-audit-panel-grid");
var addButton = document.getElementById("js-add-audit-panel");
var panelUrl = "{{ audit_panel_url }}";
var lineLimitSelect = document.getElementById("js-audit-line-limit");
var chartsInitialized = false;
var auditPath = "{{ request.path }}";
function parseChartData(scriptId) {
var script = document.getElementById(scriptId);
if (!script) {
return null;
}
try {
return JSON.parse(script.textContent);
} catch (err) {
return null;
}
}
function renderBarChart(canvasId, emptyId, data, options) {
var canvas = document.getElementById(canvasId);
var emptyNode = document.getElementById(emptyId);
var chartOpts = options || {};
if (!canvas) {
return;
}
if (!window.Chart || !data || !Array.isArray(data.labels) || data.labels.length === 0) {
canvas.classList.add("hidden");
if (emptyNode) {
emptyNode.classList.remove("hidden");
}
return;
}
if (emptyNode) {
emptyNode.classList.add("hidden");
}
canvas.classList.remove("hidden");
var chartData = {
labels: data.labels,
datasets: [
{
label: chartOpts.datasetLabel || "Events",
data: data.values,
backgroundColor: data.colors,
borderRadius: 4,
borderSkipped: false,
maxBarThickness: chartOpts.maxBarThickness || 36
}
]
};
new window.Chart(canvas.getContext("2d"), {
type: "bar",
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: chartOpts.indexAxis || "x",
plugins: {
legend: {
display: true,
position: "top",
align: "center",
labels: {
color: "#e2e8f0",
boxWidth: 18,
boxHeight: 8,
font: {
size: 12,
weight: "600"
}
}
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.94)",
borderColor: "rgba(148, 163, 184, 0.35)",
borderWidth: 1,
titleColor: "#e2e8f0",
bodyColor: "#cbd5e1",
callbacks: {
label: function (ctx) {
var isHorizontal = chartOpts.indexAxis === "y";
var value = isHorizontal ? ctx.parsed.x : ctx.parsed.y;
return "Count: " + value;
}
}
}
},
scales: {
x: {
beginAtZero: true,
ticks: {
precision: 0,
color: "#94a3b8"
},
grid: {
color: "rgba(148, 163, 184, 0.12)",
drawBorder: false
}
},
y: {
ticks: {
color: "#cbd5e1"
},
grid: {
color: "rgba(148, 163, 184, 0.08)",
drawBorder: false
}
}
},
onClick: function (_, activeElements) {
if (!activeElements || !activeElements.length) {
return;
}
var index = activeElements[0].index;
var query = (data.drilldowns || [])[index] || "";
if (!query) {
return;
}
window.location.href = auditPath + "?" + query;
},
onHover: function (event, activeElements) {
var target = event && event.native ? event.native.target : null;
if (!target || !target.style) {
return;
}
target.style.cursor = activeElements && activeElements.length ? "pointer" : "default";
}
}
});
}
function initializeCharts() {
if (chartsInitialized) {
return;
}
chartsInitialized = true;
renderBarChart("js-login-chart", "js-login-chart-empty", parseChartData("js-login-chart-data"), {
datasetLabel: "Logins"
});
renderBarChart("js-http-status-chart", "js-http-status-chart-empty", parseChartData("js-http-status-chart-data"), {
datasetLabel: "HTTP status"
});
renderBarChart("js-security-events-chart", "js-security-events-chart-empty", parseChartData("js-security-events-chart-data"), {
datasetLabel: "Events"
});
renderBarChart("js-failed-ips-chart", "js-failed-ips-chart-empty", parseChartData("js-failed-ips-chart-data"), {
datasetLabel: "Failed logins",
indexAxis: "y",
maxBarThickness: 24
});
}
function setSubpageButtonState(button, isActive) {
if (isActive) {
button.classList.remove("bg-gray-100", "text-gray-600", "hover:bg-white", "hover:text-gray-900");
button.classList.add("bg-blue-50", "text-blue-700", "ring-1", "ring-blue-100");
return;
}
button.classList.remove("bg-blue-50", "text-blue-700", "ring-1", "ring-blue-100");
button.classList.add("bg-gray-100", "text-gray-600", "hover:bg-white", "hover:text-gray-900");
}
function setSubpage(view, updateUrl) {
var target = view === "charts" ? "charts" : "logs";
if (chartsSection) {
chartsSection.classList.toggle("hidden", target !== "charts");
}
if (logsSection) {
logsSection.classList.toggle("hidden", target !== "logs");
}
if (logControls) {
logControls.classList.toggle("hidden", target !== "logs");
}
if (target === "charts") {
initializeCharts();
}
for (var i = 0; i < subpageButtons.length; i += 1) {
var button = subpageButtons[i];
setSubpageButtonState(button, button.getAttribute("data-audit-subpage") === target);
}
if (!updateUrl || !window.history || !window.history.replaceState) {
return;
}
var url = new URL(window.location.href);
url.searchParams.set("view", target);
window.history.replaceState({}, "", url.toString());
}
function getSelectedLineLimit() {
if (!lineLimitSelect) {
return "{{ audit_line_limit }}";
}
return lineLimitSelect.value || "{{ audit_line_limit }}";
}
function withLineLimit(queryString) {
var params = new URLSearchParams(queryString || "");
params.set("line_limit", getSelectedLineLimit());
return params.toString();
}
function updateGridLayout() {
if (!grid) {
return;
}
var panels = grid.querySelectorAll("article").length;
if (panels > 1) {
grid.classList.add("lg:grid-cols-2");
} else {
grid.classList.remove("lg:grid-cols-2");
}
}
function setCloseButtons() {
if (!grid) {
return;
}
var panels = grid.querySelectorAll("article");
for (var i = 0; i < panels.length; i += 1) {
var closeBtn = panels[i].querySelector(".js-close-panel");
if (!closeBtn) {
continue;
}
closeBtn.disabled = panels.length <= 1;
}
}
function loadPanelContent(contentNode, queryString) {
if (!contentNode) {
return;
}
var url = panelUrl;
var normalizedQuery = withLineLimit(queryString);
if (normalizedQuery) {
url += (url.indexOf("?") === -1 ? "?" : "&") + normalizedQuery;
}
fetch(url, {
credentials: "same-origin",
headers: { "X-Requested-With": "XMLHttpRequest" }
})
.then(function (response) {
if (!response.ok) {
throw new Error("Failed to load panel");
}
return response.text();
})
.then(function (html) {
contentNode.innerHTML = html;
})
.catch(function () {
contentNode.innerHTML = "<p class=\"text-sm text-rose-700\">Could not load panel data.</p>";
});
}
function createPanel(queryString) {
if (!grid) {
return;
}
panelCounter += 1;
var article = document.createElement("article");
article.className = "rounded-2xl border border-gray-200 bg-white shadow-sm";
article.innerHTML =
"<div class=\"flex items-center justify-between border-b border-gray-100 px-4 py-3\">" +
"<h3 class=\"text-sm font-semibold text-gray-900\">Panel " + panelCounter + "</h3>" +
"<div class=\"flex items-center gap-2\">" +
"<button type=\"button\" class=\"js-duplicate-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50\">Duplicate</button>" +
"<button type=\"button\" class=\"js-close-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50\">Close</button>" +
"</div>" +
"</div>" +
"<div class=\"js-panel-content p-4\"><p class=\"text-sm text-gray-500\">Loading…</p></div>";
grid.appendChild(article);
setCloseButtons();
updateGridLayout();
loadPanelContent(article.querySelector(".js-panel-content"), queryString || "");
}
for (var subpageIndex = 0; subpageIndex < subpageButtons.length; subpageIndex += 1) {
subpageButtons[subpageIndex].addEventListener("click", function (event) {
var target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
setSubpage(target.getAttribute("data-audit-subpage") || "logs", true);
});
}
setSubpage("{{ audit_subpage }}", false);
if (addButton) {
addButton.addEventListener("click", function () {
createPanel("");
});
}
if (lineLimitSelect && grid) {
lineLimitSelect.addEventListener("change", function () {
var panels = grid.querySelectorAll("article");
for (var i = 0; i < panels.length; i += 1) {
var panel = panels[i];
var form = panel.querySelector(".js-audit-panel-form");
var queryString = "";
if (form) {
queryString = new URLSearchParams(new FormData(form)).toString();
}
loadPanelContent(panel.querySelector(".js-panel-content"), queryString);
}
});
}
if (!grid) {
return;
}
grid.addEventListener("click", function (event) {
var target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.classList.contains("js-close-panel")) {
var panel = target.closest("article");
if (!panel) {
return;
}
panel.remove();
setCloseButtons();
updateGridLayout();
return;
}
if (target.classList.contains("js-duplicate-panel")) {
var currentPanel = target.closest("article");
if (!currentPanel) {
return;
}
var form = currentPanel.querySelector(".js-audit-panel-form");
var queryString = "";
if (form) {
queryString = new URLSearchParams(new FormData(form)).toString();
}
createPanel(queryString);
return;
}
if (target.classList.contains("js-panel-link")) {
event.preventDefault();
var href = target.getAttribute("href") || "";
var idx = href.indexOf("?");
var query = idx >= 0 ? href.substring(idx + 1) : "";
var panelForLink = target.closest("article");
if (!panelForLink) {
return;
}
loadPanelContent(panelForLink.querySelector(".js-panel-content"), query);
}
});
grid.addEventListener("submit", function (event) {
var form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if (!form.classList.contains("js-audit-panel-form")) {
return;
}
event.preventDefault();
var panel = form.closest("article");
if (!panel) {
return;
}
var queryString = new URLSearchParams(new FormData(form)).toString();
loadPanelContent(panel.querySelector(".js-panel-content"), queryString);
});
setCloseButtons();
updateGridLayout();
})();
</script>
{% endblock %}
+112 -83
View File
@@ -7,7 +7,7 @@
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Servers</h1>
<p class="mt-1 text-sm text-gray-500">Review the servers you can access and their certificate status.</p>
<p class="mt-1 text-sm text-gray-500">Request access, review approvals, and open servers you are approved for.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
{{ servers|length }} total
@@ -18,93 +18,122 @@
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% for item in servers %}
<article class="flex h-full flex-col rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-700 text-sm font-semibold text-white">
{{ item.server.initial }}
{% if item.access.granted %}
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-700 text-sm font-semibold text-white">
{{ item.server.initial }}
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.display_name }}</h2>
<p class="text-xs text-gray-500">
{{ item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.display_name }}</h2>
<p class="text-xs text-gray-500">
{{ item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<div class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
<div class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
</div>
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if item.expires_at %}
{{ item.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if item.expires_at %}
{{ item.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt>Scopes</dt>
<dd class="font-medium text-gray-900">
<div class="flex flex-wrap justify-end gap-1">
{% if item.access.can_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if item.access.can_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if item.access.can_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</dd>
</div>
</dl>
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
<span>Certificates and access</span>
{% if item.access.can_users %}
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
{% elif item.access.can_logs %}
<a href="{% url 'servers:audit' item.server.id %}" class="font-semibold text-blue-700 hover:underline">Open audit</a>
{% elif item.access.can_shell %}
<a href="{% url 'servers:shell' item.server.id %}" class="font-semibold text-blue-700 hover:underline">Open shell</a>
{% endif %}
</div>
<div class="flex items-center justify-between py-2">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if item.last_accessed %}
{{ item.last_accessed|date:"M j, Y H:i" }}
{% else %}
—
{% endif %}
</dd>
{% else %}
<div class="flex items-start justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.hostname|default:"Unassigned" }}</h2>
<p class="mt-1 text-xs text-gray-500">No access granted</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Hostname only</span>
</div>
</dl>
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
<span>Certificates and access</span>
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
</div>
<div class="mt-5 border-t border-gray-100 pt-4">
{% if item.access.pending %}
<span class="inline-flex items-center rounded-lg bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-800">
Request pending since {{ item.access.pending.requested_at|date:"M j, Y H:i" }}
</span>
{% else %}
<form method="post" action="{% url 'servers:request_access' item.server.id %}">
{% csrf_token %}
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800">
Request access
</button>
</form>
{% endif %}
<p class="mt-2 text-xs text-gray-500">Request includes shell, logs, and users scopes.</p>
</div>
{% endif %}
</article>
{% endfor %}
</div>
@@ -116,8 +145,8 @@
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No server access yet</h2>
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it listed here.</p>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No servers yet</h2>
<p class="mt-2 text-sm text-gray-600">Create or enroll a server to start handling access requests.</p>
</div>
{% endif %}
</div>
@@ -0,0 +1,193 @@
{% extends "base.html" %}
{% load tz %}
{% block title %}Admin • {{ server.display_name }} • Keywarden{% endblock %}
{% block content %}
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Server admin utilities</h2>
<p class="mt-1 text-sm text-gray-500">Server-scoped administration and maintenance actions.</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ admin_server_url }}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
Edit server
</a>
<a href="{{ admin_log_sources_url }}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Log sources
</a>
</div>
</div>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-700">
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Agent enrolled</dt>
<dd class="font-medium text-gray-900">{% if server.agent_enrolled_at %}Yes{% else %}No{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Enrollment timestamp</dt>
<dd class="font-medium text-gray-900">{{ server.agent_enrolled_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.agent_enrolled_at %} UTC{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Last heartbeat</dt>
<dd class="font-medium text-gray-900">{{ server.last_heartbeat_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.last_heartbeat_at %} UTC{% endif %}</dd>
</div>
</dl>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Pending access requests</h2>
<p class="mt-1 text-sm text-gray-500">Approve or deny pending requests for this server.</p>
</div>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{{ pending_access_requests|length }} pending
</span>
</div>
{% if pending_access_requests %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Requester</th>
<th class="px-3 py-2 font-semibold">Requested scopes</th>
<th class="px-3 py-2 font-semibold">Requested at</th>
<th class="px-3 py-2 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for req in pending_access_requests %}
<tr>
<td class="px-3 py-2">{{ req.requester.username }}</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-2">
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
</form>
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="deny">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No pending requests for this server.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Users with access</h2>
<p class="mt-1 text-sm text-gray-500">Effective access from approved requests and object permissions.</p>
</div>
{% if users_with_access %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">User</th>
<th class="px-3 py-2 font-semibold">Scopes</th>
<th class="px-3 py-2 font-semibold">Source</th>
<th class="px-3 py-2 font-semibold">Requests</th>
<th class="px-3 py-2 font-semibold">Expires</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for entry in users_with_access %}
<tr>
<td class="px-3 py-2">
<div class="font-medium text-gray-900">{{ entry.user.username }}</div>
<div class="text-xs text-gray-500">{{ entry.user.email|default:"—" }}</div>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if entry.can_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if entry.can_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if entry.can_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">
{% for label in entry.source_labels %}
<span class="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-700">{{ label }}</span>
{% endfor %}
</td>
<td class="px-3 py-2">{{ entry.request_count }}</td>
<td class="px-3 py-2">
{% if entry.expires_at %}
{{ entry.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No users currently have access on this server.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Server accounts</h2>
<p class="mt-1 text-sm text-gray-500">Accounts currently tracked by agent synchronization.</p>
</div>
{% if server_accounts %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">System username</th>
<th class="px-3 py-2 font-semibold">User</th>
<th class="px-3 py-2 font-semibold">Present</th>
<th class="px-3 py-2 font-semibold">Last synced</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for account in server_accounts %}
<tr>
<td class="px-3 py-2 font-medium text-gray-900">{{ account.system_username }}</td>
<td class="px-3 py-2">{{ account.user.username }}</td>
<td class="px-3 py-2">
{% if account.is_present %}
<span class="inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-700">Present</span>
{% else %}
<span class="inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold text-rose-700">Missing</span>
{% endif %}
</td>
<td class="px-3 py-2">{{ account.last_synced_at|utc|date:"M j, Y H:i:s" }} UTC</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No synchronized server accounts yet.</p>
{% endif %}
</section>
</div>
{% endblock %}
+120 -11
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load tz %}
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
@@ -7,22 +8,130 @@
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Settings</h2>
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</p>
<h2 class="text-lg font-semibold text-gray-900">Server status</h2>
<p class="mt-1 text-sm text-gray-500">Current enrollment and telemetry posture for this host.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-700">
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Agent enrolled</dt>
<dd class="font-medium text-gray-900">{% if server.agent_enrolled_at %}Yes{% else %}No{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Enrollment timestamp</dt>
<dd class="font-medium text-gray-900">{{ server.agent_enrolled_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.agent_enrolled_at %} UTC{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Last heartbeat</dt>
<dd class="font-medium text-gray-900">{{ server.last_heartbeat_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.last_heartbeat_at %} UTC{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Last ping</dt>
<dd class="font-medium text-gray-900">{% if server.last_ping_ms is not None %}{{ server.last_ping_ms }}ms{% else %}—{% endif %}</dd>
</div>
</dl>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Audit pipeline</h2>
<p class="mt-1 text-sm text-gray-500">Visibility into agent log ingestion and category distribution.</p>
</div>
<p class="mt-3 text-sm text-gray-600">Settings will appear here as server options are added.</p>
</div>
<dl class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs uppercase tracking-wide text-gray-500">Total logs</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900">{{ audit_summary.total_count }}</dd>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs uppercase tracking-wide text-gray-500">Last 24h</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900">{{ audit_summary.recent_24h_count }}</dd>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs uppercase tracking-wide text-gray-500">Last event</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_event_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_event_at %} UTC{% endif %}</dd>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs uppercase tracking-wide text-gray-500">Last ingest</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_ingest_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_ingest_at %} UTC{% endif %}</dd>
</div>
</dl>
<div class="mt-4">
<h3 class="text-sm font-semibold text-gray-900">Top categories</h3>
{% if audit_summary.top_categories %}
<ul class="mt-2 space-y-2 text-sm text-gray-700">
{% for item in audit_summary.top_categories %}
<li class="flex items-center justify-between rounded-lg border border-gray-100 px-3 py-2">
<span class="font-medium text-gray-900">{{ item.category }}</span>
<span class="text-gray-500">{{ item.total }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-gray-500">No agent logs have been ingested yet.</p>
{% endif %}
</div>
<div class="mt-5 border-t border-gray-100 pt-4">
<a href="{% url 'servers:audit' server.id %}" class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-xs font-semibold text-white hover:bg-blue-800">
Open audit log viewer
</a>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Log source configuration</h2>
<p class="mt-1 text-sm text-gray-500">These sources are consumed by the agent from the central server config.</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ admin_server_url }}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
Configure Inline
</a>
<a href="{{ admin_log_sources_url }}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Open Log Source Admin
</a>
</div>
</div>
{% if log_sources %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Type</th>
<th class="px-3 py-2 font-semibold">Name</th>
<th class="px-3 py-2 font-semibold">Service/File</th>
<th class="px-3 py-2 font-semibold">Parser</th>
<th class="px-3 py-2 font-semibold">Matches</th>
<th class="px-3 py-2 font-semibold">Overrides</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for source in log_sources %}
<tr>
<td class="px-3 py-2">{{ source.kind }}</td>
<td class="px-3 py-2">{{ source.name|default:"—" }}</td>
<td class="px-3 py-2">{{ source.service_unit|default:source.file_path|default:"—" }}</td>
<td class="px-3 py-2">{{ source.parser|default:"none" }}</td>
<td class="px-3 py-2 text-xs text-gray-600">
in={{ source.include_matches|default:"{}" }}<br>
out={{ source.exclude_matches|default:"{}" }}
</td>
<td class="px-3 py-2">
{{ source.category_override|default:"(category default)" }} / {{ source.event_type_override|default:"(event default)" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No custom log sources defined. Defaults are used by the agent.</p>
{% endif %}
<p class="mt-4 text-xs text-gray-500">Use the admin buttons above to configure per-server monitors (journal/service/file), parser mode, and include/exclude matches.</p>
</section>
</div>
{% endblock %}
+39 -8
View File
@@ -240,6 +240,7 @@
var startButton = document.getElementById("shell-start");
var activeSocket = null;
var activeTerm = null;
var activeResizeHandler = null;
var popoutShell = document.getElementById("shell-popout-shell");
var isPopout = {{ is_popout|yesno:"true,false" }};
@@ -283,19 +284,40 @@
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
}
function stopTerminal() {
if (activeSocket) {
function disposeTerminal() {
if (activeResizeHandler) {
window.removeEventListener("resize", activeResizeHandler);
activeResizeHandler = null;
}
if (activeTerm) {
try {
activeTerm.dispose();
} catch (err) {
// noop
}
activeTerm = null;
}
if (termContainer) {
termContainer.innerHTML = "";
}
}
function stopTerminal(options) {
var opts = options || {};
if (!opts.skipSocketClose && activeSocket) {
try {
activeSocket.close();
} catch (err) {
// noop
}
}
activeSocket = null;
if (termContainer) {
termContainer.dataset.started = "0";
}
activeSocket = null;
activeTerm = null;
if (opts.disposeTerminal) {
disposeTerminal();
}
setButtonState(false);
}
@@ -303,6 +325,7 @@
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
return;
}
disposeTerminal();
termContainer.dataset.started = "1";
if (startButton) {
startButton.disabled = true;
@@ -343,10 +366,18 @@
socket.onclose = function () {
term.write("\r\nSession closed.\r\n");
if (activeSocket === socket) {
stopTerminal();
// Keep the closed terminal output visible until the next start.
stopTerminal({
skipSocketClose: true,
disposeTerminal: false,
});
}
};
socket.onerror = function () {
term.write("\r\nConnection error.\r\n");
};
term.onData(function (data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
@@ -356,11 +387,11 @@
setButtonState(true);
if (isPopout) {
var onResize = function () {
activeResizeHandler = function () {
sizePopoutTerminal();
fitTerminal(term);
};
window.addEventListener("resize", onResize);
window.addEventListener("resize", activeResizeHandler);
}
}
@@ -373,7 +404,7 @@
if (startButton) {
startButton.addEventListener("click", function () {
if (termContainer.dataset.started === "1") {
stopTerminal();
stopTerminal({ disposeTerminal: true });
return;
}
startTerminal();