Refactor to Flowbite for UI

This commit is contained in:
2026-02-03 09:54:49 +00:00
parent 962ba27679
commit bebaaf1367
16 changed files with 706 additions and 337 deletions

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0008_remove_server_host_key"),
]
operations = [
migrations.AddField(
model_name="server",
name="last_heartbeat_at",
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="server",
name="last_ping_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@@ -28,6 +28,8 @@ class Server(models.Model):
agent_enrolled_at = models.DateTimeField(null=True, blank=True)
agent_cert_fingerprint = models.CharField(max_length=128, null=True, blank=True)
agent_cert_serial = models.CharField(max_length=64, null=True, blank=True)
last_heartbeat_at = models.DateTimeField(null=True, blank=True, db_index=True)
last_ping_ms = models.PositiveIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -1,42 +1,113 @@
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white text-xl font-semibold">
{{ server.initial }}
<div class="space-y-4">
<nav class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 text-sm text-gray-500">
<li class="inline-flex items-center">
<a href="{% url 'servers:dashboard' %}" class="inline-flex items-center gap-1 font-medium text-gray-600 hover:text-blue-700">
<svg class="h-4 w-4" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 3.172 2 10v7a1 1 0 0 0 1 1h5v-5h4v5h5a1 1 0 0 0 1-1v-7l-8-6.828Z"></path>
</svg>
Servers
</a>
</li>
<li class="inline-flex items-center">
<svg class="h-4 w-4 text-gray-400" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M7.05 4.55a1 1 0 0 1 1.4-1.42l6 5.9a1 1 0 0 1 0 1.42l-6 5.9a1 1 0 1 1-1.4-1.42L12.5 10 7.05 4.55Z"></path>
</svg>
<span class="ml-1 font-medium text-gray-700">{{ server.display_name }}</span>
</li>
</ol>
</nav>
<div class="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-700 text-lg font-semibold text-white shadow-sm">
{{ server.initial }}
</div>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ server.display_name }}</h1>
<p class="text-sm text-gray-500">
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ server.display_name }}</h1>
<p class="text-sm text-gray-600">
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
</p>
<div class="flex items-center gap-2">
<div class="relative">
<button
type="button"
data-tooltip-target="server-header-status-{{ server.id }}"
class="{% if server_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 %}"
>
{{ server_status.label }}: {{ server_status.detail }}
</button>
<div
id="server-header-status-{{ 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">{{ server_status.label }}: {{ server_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 server_status.ping_ms is not None %}{{ server_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">{{ 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">{{ 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">{{ 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 server_status.heartbeat_at %}{{ server_status.heartbeat_at|date:"M j, Y H:i" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<a href="{% url 'servers:dashboard' %}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50">
Back to servers
</a>
</div>
</div>
<a href="{% url 'servers:dashboard' %}" class="text-sm font-semibold text-purple-700 hover:text-purple-800">Back to servers</a>
</div>
<nav class="flex flex-wrap gap-2 border-b border-gray-200 pb-2 text-sm font-semibold text-gray-500">
<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-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}"
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-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}"
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_shell %}
<a
href="{% url 'servers:shell' server.id %}"
class="{% if active_tab == 'shell' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}"
class="{% if active_tab == 'shell' %}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 %}"
>
Shell
</a>
{% endif %}
<a
href="{% url 'servers:settings' server.id %}"
class="{% if active_tab == 'settings' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}"
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>

View File

@@ -3,26 +3,43 @@
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %}
{% block content %}
<div class="space-y-8">
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2>
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
<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-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600">
Logs will appear here once collection is enabled for this server.
<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>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2>
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
<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>
</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-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600">
Metrics will appear here once collection is enabled for this server.
<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>
</div>
<p class="mt-3 text-sm text-gray-600">Metrics will appear here once collection is enabled for this server.</p>
</div>
</section>
</div>

View File

@@ -3,15 +3,24 @@
{% block title %}Servers • Keywarden{% endblock %}
{% block content %}
<div class="space-y-8">
<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">Servers</h1>
<p class="mt-1 text-sm text-gray-500">Review the servers you can access and their certificate status.</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
</span>
</div>
{% if servers %}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% for item in servers %}
<article class="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<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-lg bg-purple-600 text-white font-semibold">
<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>
@@ -21,11 +30,56 @@
</p>
</div>
</div>
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700">Active</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" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
</div>
<dl class="mt-4 space-y-2 text-sm text-gray-600">
<div class="flex items-center justify-between">
<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 %}
@@ -35,7 +89,7 @@
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between py-2">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if item.last_accessed %}
@@ -47,16 +101,23 @@
</div>
</dl>
<div class="mt-4 border-t border-gray-100 pt-3 text-xs text-gray-500">
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-purple-700 hover:text-purple-800">View details and logs</a>
<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>
</article>
{% endfor %}
</div>
{% else %}
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center">
<h2 class="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 here.</p>
<div class="rounded-2xl border border-dashed border-gray-200 bg-white p-10 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>
<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>
</div>
{% endif %}
</div>

View File

@@ -3,32 +3,39 @@
{% block title %}{{ server.display_name }} • Keywarden{% endblock %}
{% block content %}
<div class="space-y-8">
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Server details</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Hostname</dt>
<dd class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between py-2">
<dt>IPv4</dt>
<dd class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between py-2">
<dt>IPv6</dt>
<dd class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</dd>
</div>
</dl>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm lg:col-span-2">
<h2 class="text-lg font-semibold text-gray-900">Account & certificate</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Account & certificate</h2>
<p class="mt-1 text-sm text-gray-500">Credentials and certificate download options.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Access</span>
</div>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Account name</dt>
<dd class="font-medium text-gray-900">
{% if system_username %}
@@ -38,7 +45,7 @@
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between py-2">
<dt>Account status</dt>
<dd class="font-medium text-gray-900">
{% if account_present is None %}
@@ -50,22 +57,22 @@
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt>Certificate</dt>
<dd class="font-medium text-gray-900">
{% if certificate_key_id %}
<div class="flex items-center gap-2">
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
<div class="flex flex-wrap items-center gap-2">
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
@@ -73,7 +80,7 @@
</div>
<button
type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert"
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 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Regenerate
@@ -87,10 +94,16 @@
</dl>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm lg:col-span-3">
<h2 class="text-lg font-semibold text-gray-900">Access</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-3">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Access</h2>
<p class="mt-1 text-sm text-gray-500">Review access windows and last usage.</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">Usage</span>
</div>
<dl class="mt-4 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 expires_at %}
@@ -100,7 +113,7 @@
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between py-2">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if last_accessed %}

View File

@@ -3,16 +3,25 @@
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
{% block content %}
<div class="space-y-8">
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Settings</h2>
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
<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">Settings</h2>
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</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-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600">
Settings will appear here as server options are added.
<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">Settings will appear here as server options are added.</p>
</div>
</section>
</div>

View File

@@ -18,25 +18,25 @@
{% block content %}
{% if is_popout %}
<div class="w-screen">
<div id="shell-popout-shell" class="w-full border border-gray-200 bg-gray-900 shadow-sm">
<div id="shell-terminal" class="h-full w-full p-2"></div>
<div id="shell-popout-shell" class="w-full rounded-2xl border border-gray-200 bg-slate-950 shadow-sm">
<div id="shell-terminal" class="h-full w-full p-3"></div>
</div>
</div>
{% else %}
<div class="space-y-8">
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Shell access</h2>
<p class="mt-1 text-sm text-gray-600">
<p class="mt-1 text-sm text-gray-500">
Connect with your private key and the signed certificate for this server.
</p>
</div>
<button
type="button"
class="inline-flex items-center rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
id="shell-popout"
data-popout-url="{% url 'servers:shell' server.id %}?popout=1"
>
@@ -44,7 +44,7 @@
</button>
</div>
<div class="mt-4 space-y-4 text-sm text-gray-600">
<div class="mt-5 grid gap-4 text-sm text-gray-600 lg:grid-cols-2">
<dl class="space-y-4">
<div class="flex items-center justify-between">
<dt>Account name</dt>
@@ -69,46 +69,46 @@
</dl>
{% if shell_command %}
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4">
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs font-semibold text-gray-500">SSH command</span>
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">SSH command</span>
<button
type="button"
class="text-xs font-semibold text-purple-700 hover:text-purple-800"
class="text-xs font-semibold text-blue-700 hover:underline"
data-copy-target="shell-command"
>
Copy command
</button>
</div>
<code class="mt-2 block break-all text-xs text-gray-800" id="shell-command">{{ shell_command }}</code>
</div>
<div class="flex flex-wrap items-center gap-2">
{% if certificate_key_id %}
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
<code class="mt-3 block break-all rounded-lg bg-white p-3 text-xs text-gray-800" id="shell-command">{{ shell_command }}</code>
<div class="mt-4 flex flex-wrap items-center gap-2">
{% if certificate_key_id %}
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
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 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Download
Regenerate
</button>
<button
type="button"
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Regenerate
</button>
{% endif %}
<span class="text-xs text-gray-500">Use the command above for local SSH.</span>
{% endif %}
<span class="text-xs text-gray-500">Use the command above for local SSH.</span>
</div>
</div>
{% else %}
<p class="text-sm text-gray-600">Upload a key to enable downloads and a local SSH command.</p>
@@ -116,26 +116,26 @@
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Browser terminal</h2>
<p class="mt-1 text-sm text-gray-600">
<p class="mt-1 text-sm text-gray-500">
Launch a proxied terminal session to the target host in your browser.
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-gray-500">Beta</span>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">Beta</span>
<button
type="button"
class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
id="shell-start"
>
Start terminal
</button>
</div>
</div>
<div class="mt-4 rounded-xl border border-gray-200 bg-gray-900 p-2">
<div class="mt-4 rounded-xl border border-gray-200 bg-slate-950 p-2">
<div id="shell-terminal" class="h-96"></div>
</div>
<p class="mt-3 text-xs text-gray-500">
@@ -279,8 +279,8 @@
startButton.textContent = isRunning ? "Stop terminal" : "Start terminal";
startButton.classList.toggle("bg-red-600", isRunning);
startButton.classList.toggle("hover:bg-red-700", isRunning);
startButton.classList.toggle("bg-purple-600", !isRunning);
startButton.classList.toggle("hover:bg-purple-700", !isRunning);
startButton.classList.toggle("bg-blue-700", !isRunning);
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
}
function stopTerminal() {
@@ -313,9 +313,9 @@
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: 13,
theme: {
background: "#0b0f12",
foreground: "#d1d5db",
cursor: "#a855f7"
background: "#0b1120",
foreground: "#e2e8f0",
cursor: "#38bdf8"
}
});
term.open(termContainer);

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import Http404
@@ -44,14 +47,16 @@ def dashboard(request):
if expires_at is None or expires_at > current:
expires_map[access.server_id] = expires_at
servers = [
{
"server": server,
"expires_at": expires_map.get(server.id),
"last_accessed": None,
}
for server in server_qs
]
servers = []
for server in server_qs:
servers.append(
{
"server": server,
"expires_at": expires_map.get(server.id),
"last_accessed": None,
"status": _build_server_status(server, now),
}
)
context = {
"servers": servers,
@@ -89,12 +94,14 @@ def detail(request, server_id: int):
"certificate_key_id": certificate_key_id,
"active_tab": "details",
"can_shell": can_shell,
"server_status": _build_server_status(server, now),
}
return render(request, "servers/detail.html", context)
@login_required(login_url="/accounts/login/")
def shell(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id)
# We intentionally return a 404 on denied shell access to avoid
# disclosing that the server exists but is restricted.
@@ -122,28 +129,33 @@ def shell(request, server_id: int):
"active_tab": "shell",
"is_popout": request.GET.get("popout") == "1",
"can_shell": True,
"server_status": _build_server_status(server, now),
}
return render(request, "servers/shell.html", context)
@login_required(login_url="/accounts/login/")
def audit(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id)
context = {
"server": server,
"active_tab": "audit",
"can_shell": user_can_shell(request.user, server),
"server_status": _build_server_status(server, now),
}
return render(request, "servers/audit.html", context)
@login_required(login_url="/accounts/login/")
def settings(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id)
context = {
"server": server,
"active_tab": "settings",
"can_shell": user_can_shell(request.user, server),
"server_status": _build_server_status(server, now),
}
return render(request, "servers/settings.html", context)
@@ -170,3 +182,47 @@ def _load_account_context(request, server: Server):
active_key = SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
certificate_key_id = active_key.id if active_key else None
return account, system_username, certificate_key_id
def _format_age_short(delta: timedelta) -> str:
seconds = max(0, int(delta.total_seconds()))
if seconds < 60:
return f"{seconds}s"
minutes = seconds // 60
if minutes < 60:
return f"{minutes}m"
hours = minutes // 60
if hours < 48:
return f"{hours}h"
days = hours // 24
if days < 14:
return f"{days}d"
weeks = days // 7
return f"{weeks}w"
def _build_server_status(server: Server, now):
stale_seconds = int(getattr(settings, "KEYWARDEN_HEARTBEAT_STALE_SECONDS", 120))
heartbeat_at = getattr(server, "last_heartbeat_at", None)
ping_ms = getattr(server, "last_ping_ms", None)
if heartbeat_at:
age = now - heartbeat_at
age_seconds = max(0, int(age.total_seconds()))
is_active = age_seconds <= stale_seconds
age_short = _format_age_short(age)
else:
is_active = False
age_short = "never"
label = "Active" if is_active else "Inactive"
if is_active:
detail = f"{ping_ms}ms" if ping_ms is not None else ""
else:
detail = age_short
return {
"is_active": is_active,
"label": label,
"detail": detail,
"ping_ms": ping_ms,
"age_short": age_short,
"heartbeat_at": heartbeat_at,
}