Refactor to Flowbite for UI
This commit is contained in:
@@ -8,7 +8,7 @@ class ErasureRequestForm(forms.Form):
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"placeholder": "Explain why you are requesting data erasure.",
|
||||
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600",
|
||||
"class": "block w-full resize-y rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
|
||||
}
|
||||
),
|
||||
min_length=10,
|
||||
@@ -18,22 +18,22 @@ class ErasureRequestForm(forms.Form):
|
||||
|
||||
class SSHKeyForm(forms.Form):
|
||||
name = forms.CharField(
|
||||
label="Key name",
|
||||
label="Key Name",
|
||||
max_length=128,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "MacBook Pro",
|
||||
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600",
|
||||
"placeholder": "Device Name",
|
||||
"class": "block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
|
||||
}
|
||||
),
|
||||
)
|
||||
public_key = forms.CharField(
|
||||
label="SSH public key",
|
||||
label="SSH Public Key",
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 4,
|
||||
"placeholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB... you@host",
|
||||
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600",
|
||||
"placeholder": "ssh-ed25519 AAAaBBbBcCcc111122223333... user@host",
|
||||
"class": "block w-full resize-y rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,32 +4,53 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Sign in</h1>
|
||||
<form method="post" class="space-y-4">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Welcome back</h1>
|
||||
<p class="text-sm text-gray-500">Sign in to manage server access and certificates.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-6 space-y-5">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-gray-700">Username</label>
|
||||
<input type="text" name="username" autocomplete="username" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-900">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input type="password" name="password" autocomplete="current-password" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-900">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
|
||||
>
|
||||
</div>
|
||||
{% if form.errors %}
|
||||
<p class="text-sm text-red-600">Please check your username and password.</p>
|
||||
<div class="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
|
||||
<span class="font-medium">Sign-in failed.</span>
|
||||
<span>Please check your username and password.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="inline-flex w-full items-center justify-center rounded-md bg-purple-600 px-4 py-2.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
||||
<p class="text-sm text-gray-600">
|
||||
Or, if configured, use
|
||||
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">OIDC login</a>.
|
||||
<a href="/oidc/authenticate/" class="font-medium text-blue-700 hover:underline">OIDC login</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,136 +3,225 @@
|
||||
{% block title %}Profile • Keywarden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1>
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Username</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ user.username }}</dd>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1>
|
||||
<p class="text-sm text-gray-500">Account details and contact information.</p>
|
||||
</div>
|
||||
<dl class="mt-6 grid grid-cols-1 gap-4 text-sm text-gray-600 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Username</dt>
|
||||
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.username }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Email</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ user.email }}</dd>
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Email</dt>
|
||||
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.email }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">First name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ user.first_name|default:"—" }}</dd>
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">First name</dt>
|
||||
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.first_name|default:"—" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Last name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ user.last_name|default:"—" }}</dd>
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Last name</dt>
|
||||
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.last_name|default:"—" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h2 class="mb-4 text-base font-semibold tracking-tight text-gray-900">Single Sign-On</h2>
|
||||
{% if auth_mode == "hybrid" %}
|
||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
||||
<p class="text-sm text-gray-600">
|
||||
Optional: Link your account with your identity provider for single sign-on.
|
||||
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">Link with SSO</a>
|
||||
</p>
|
||||
</div>
|
||||
{% elif auth_mode == "oidc" %}
|
||||
<p class="text-sm text-gray-600">OIDC is required. Sign-in is managed by your identity provider.</p>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-600">OIDC is disabled. You are using native authentication.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h2 class="text-base font-semibold tracking-tight text-gray-900">SSH certificates</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Upload your SSH public key to receive a signed certificate for server access.
|
||||
</p>
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
|
||||
<p class="text-sm text-gray-500">Manage how you authenticate with external providers.</p>
|
||||
</div>
|
||||
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
|
||||
{% if auth_mode == "hybrid" %}
|
||||
Optional: Link your account with your identity provider for single sign-on.
|
||||
<a href="/oidc/authenticate/" class="font-semibold text-blue-700 hover:underline">Link with SSO</a>
|
||||
{% elif auth_mode == "oidc" %}
|
||||
OIDC is required. Sign-in is managed by your identity provider.
|
||||
{% else %}
|
||||
OIDC is disabled. You are using native authentication.
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if can_add_key %}
|
||||
<form method="post" class="mt-4 space-y-3">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="form_type" value="ssh_key">
|
||||
<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>
|
||||
<label for="{{ key_form.name.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
||||
Key name
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<h2 class="text-base font-semibold text-gray-900">SSH certificates</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Upload your SSH public key to receive a signed certificate for server access.
|
||||
</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">Certificates</span>
|
||||
</div>
|
||||
|
||||
{% if can_add_key %}
|
||||
<form method="post" class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="form_type" value="ssh_key">
|
||||
<div>
|
||||
<label for="{{ key_form.name.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
|
||||
Key name
|
||||
</label>
|
||||
{{ key_form.name }}
|
||||
{% if key_form.name.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if key_form.name.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ key_form.public_key.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
||||
SSH public key
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<div class="lg:col-span-2">
|
||||
<label for="{{ key_form.public_key.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
|
||||
SSH public key
|
||||
</label>
|
||||
{{ key_form.public_key }}
|
||||
{% if key_form.public_key.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if key_form.public_key.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
|
||||
{% if key_form.non_field_errors %}
|
||||
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
|
||||
{% endif %}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
||||
>
|
||||
Upload key
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if ssh_keys %}
|
||||
<div class="mt-6 overflow-hidden rounded-xl border border-gray-200">
|
||||
<table class="w-full text-left text-sm text-gray-500">
|
||||
<thead class="bg-gray-50 text-xs uppercase text-gray-500">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">Key</th>
|
||||
<th scope="col" class="px-6 py-3">Fingerprint</th>
|
||||
<th scope="col" class="px-6 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in ssh_keys %}
|
||||
<tr class="border-t bg-white">
|
||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900">
|
||||
{{ key.name }}
|
||||
</th>
|
||||
<td class="px-6 py-4 text-xs text-gray-500">{{ key.fingerprint }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
{% if key.is_active %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Active</span>
|
||||
<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/{{ 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/{{ key.id }}/certificate.sha256"
|
||||
>
|
||||
Hash
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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="{{ key.id }}"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-0.5 text-xs font-medium text-gray-700">Revoked</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
|
||||
{% endif %}
|
||||
</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-base font-semibold text-gray-900">Data erasure request</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Submit a GDPR erasure request to anonymize your account data. An administrator
|
||||
must review and approve the request before processing.
|
||||
</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">GDPR</span>
|
||||
</div>
|
||||
|
||||
{% if erasure_request %}
|
||||
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
|
||||
{{ erasure_request.status|capfirst }}
|
||||
</span>
|
||||
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
|
||||
</div>
|
||||
{% if erasure_request.decided_at %}
|
||||
<p class="mt-2 text-gray-600">
|
||||
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
|
||||
{% if erasure_request.decision_reason %}
|
||||
Reason: {{ erasure_request.decision_reason }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if erasure_request.status == "processed" %}
|
||||
<p class="mt-2 text-gray-600">
|
||||
Your account has been anonymized. Access has been revoked and SSH keys disabled.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if key_form.non_field_errors %}
|
||||
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
|
||||
{% endif %}
|
||||
<button type="submit" class="inline-flex items-center rounded-md bg-purple-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">
|
||||
Upload key
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if ssh_keys %}
|
||||
<div class="mt-6 divide-y divide-gray-200">
|
||||
{% for key in ssh_keys %}
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900">{{ key.name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ key.fingerprint }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{% if key.is_active %}
|
||||
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
|
||||
<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/{{ 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"
|
||||
data-download-url="/api/v1/keys/{{ 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="{{ key.id }}"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-xs font-semibold text-gray-500">Revoked</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not erasure_request or erasure_request.status != "pending" %}
|
||||
<form method="post" class="mt-6 grid gap-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="form_type" value="erasure">
|
||||
<div>
|
||||
<label for="{{ erasure_form.reason.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
|
||||
Reason for request
|
||||
</label>
|
||||
{{ erasure_form.reason }}
|
||||
{% if erasure_form.reason.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
|
||||
{% endif %}
|
||||
{% if erasure_form.non_field_errors %}
|
||||
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
|
||||
{% endif %}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
||||
>
|
||||
Submit erasure request
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function getCookie(name) {
|
||||
@@ -191,61 +280,4 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h2 class="text-base font-semibold tracking-tight text-gray-900">Data erasure request</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Submit a GDPR erasure request to anonymize your account data. An administrator
|
||||
must review and approve the request before processing.
|
||||
</p>
|
||||
|
||||
{% if erasure_request %}
|
||||
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold">Status:</span>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
|
||||
{{ erasure_request.status|capfirst }}
|
||||
</span>
|
||||
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
|
||||
</div>
|
||||
{% if erasure_request.decided_at %}
|
||||
<p class="mt-2 text-gray-600">
|
||||
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
|
||||
{% if erasure_request.decision_reason %}
|
||||
Reason: {{ erasure_request.decision_reason }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if erasure_request.status == "processed" %}
|
||||
<p class="mt-2 text-gray-600">
|
||||
Your account has been anonymized. Access has been revoked and SSH keys disabled.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not erasure_request or erasure_request.status != "pending" %}
|
||||
<form method="post" class="mt-4 space-y-3">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="form_type" value="erasure">
|
||||
<div>
|
||||
<label for="{{ erasure_form.reason.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
||||
Reason for request
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ erasure_form.reason }}
|
||||
</div>
|
||||
{% if erasure_form.reason.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if erasure_form.non_field_errors %}
|
||||
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
|
||||
{% endif %}
|
||||
<button type="submit" class="inline-flex items-center rounded-md bg-purple-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">
|
||||
Submit erasure request
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
21
app/apps/servers/migrations/0009_server_heartbeat_fields.py
Normal file
21
app/apps/servers/migrations/0009_server_heartbeat_fields.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user