Jun 24 - Implement devices tracker
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Device Registry{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-tablet me-2"></i>Device Registry</h2>
|
||||
<p class="text-muted mb-0">
|
||||
iOS devices that have opened the JanitorialQC app.
|
||||
Records update on every app launch — <strong>Last Seen</strong> reflects the most recent foreground.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge bg-secondary fs-6">{{ devices|length }} device{{ 's' if devices|length != 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Device Table ──────────────────────────────────────────────────── #}
|
||||
<div class="col-xl-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<i class="bi bi-list-ul"></i>
|
||||
<strong>Registered Devices</strong>
|
||||
<span class="text-muted fw-normal ms-1 small">sorted by most recently active</span>
|
||||
<div class="ms-auto">
|
||||
<input type="text" id="deviceSearch" class="form-control form-control-sm"
|
||||
placeholder="Filter…" style="width:180px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if devices %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0" id="deviceTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>User</th>
|
||||
<th class="text-center">App Ver.</th>
|
||||
<th class="text-center">iOS Ver.</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Registered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
{% set app_ver = d.app_version or '—' %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-tablet me-1 text-muted"></i>
|
||||
{{ d.device_name or '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-semibold">{{ d.user.display_name if d.user else '—' }}</span>
|
||||
{% if d.user %}
|
||||
<br><span class="text-muted small">{{ d.user.username }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge version-badge" data-version="{{ d.app_version }}">
|
||||
{{ app_ver }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center text-muted small">{{ d.ios_version or '—' }}</td>
|
||||
<td class="text-nowrap small">
|
||||
{{ d.last_seen_at.strftime('%b %-d, %Y') }}<br>
|
||||
<span class="text-muted" style="font-size:.75rem;">
|
||||
{{ d.last_seen_at.strftime('%I:%M %p') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-nowrap small text-muted">
|
||||
{{ d.registered_at.strftime('%b %-d, %Y') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-tablet fs-1 d-block mb-2 opacity-25"></i>
|
||||
No devices registered yet. Devices appear here once the iOS app opens while online.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Send Update Notice ────────────────────────────────────────────── #}
|
||||
<div class="col-xl-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<i class="bi bi-bell-fill me-2"></i><strong>Send Update Notice</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Sends an in-app notification to every user whose device is running
|
||||
an app version <em>older</em> than the version you enter.
|
||||
Delivered within 60 seconds via the app's background poll.
|
||||
One notification per user (even if they have multiple devices).
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('devices.notify_update') }}"
|
||||
onsubmit="return confirm('Send update notice to all users on older versions?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="current_version" class="form-label fw-semibold">
|
||||
Current / Target Version <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="current_version"
|
||||
name="current_version" placeholder="e.g. 1.3.0"
|
||||
pattern="^\d+\.\d+(\.\d+)?$"
|
||||
title="Format: 1.0 or 1.3.0"
|
||||
required>
|
||||
<div class="form-text">
|
||||
Devices running a version below this will receive the notice.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="message" class="form-label fw-semibold">
|
||||
Custom Message <span class="text-muted fw-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea class="form-control" id="message" name="message"
|
||||
rows="3" maxlength="500"
|
||||
placeholder="Leave blank to use the default message."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-send-fill me-2"></i>Send Update Notice
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if devices %}
|
||||
<hr class="mt-4">
|
||||
<p class="text-muted small mb-2 fw-semibold">Version breakdown</p>
|
||||
<div id="versionBreakdown"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /row -->
|
||||
|
||||
<script>
|
||||
// ── Inline filter ──────────────────────────────────────────────────────────
|
||||
(function () {
|
||||
var input = document.getElementById('deviceSearch');
|
||||
if (!input) return;
|
||||
input.addEventListener('input', function () {
|
||||
var q = this.value.toLowerCase();
|
||||
document.querySelectorAll('#deviceTable tbody tr').forEach(function (row) {
|
||||
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Version badges + breakdown ────────────────────────────────────────────
|
||||
(function () {
|
||||
var badges = document.querySelectorAll('.version-badge[data-version]');
|
||||
if (!badges.length) return;
|
||||
|
||||
// Collect all version strings from the table
|
||||
var versions = Array.from(badges).map(function (b) { return b.dataset.version || ''; });
|
||||
|
||||
// Find the most-common latest version to use as the "current" baseline
|
||||
var freq = {};
|
||||
versions.forEach(function (v) { if (v) freq[v] = (freq[v] || 0) + 1; });
|
||||
var sorted = Object.keys(freq).sort(function (a, b) {
|
||||
return compareSemver(b, a); // desc — highest first
|
||||
});
|
||||
var latest = sorted[0] || '';
|
||||
|
||||
// Colour each badge relative to the latest version seen in the table
|
||||
badges.forEach(function (badge) {
|
||||
var v = badge.dataset.version || '';
|
||||
if (!v || v === '—') {
|
||||
badge.classList.add('bg-secondary');
|
||||
} else if (v === latest) {
|
||||
badge.classList.add('bg-success');
|
||||
} else if (compareSemver(v, latest) < 0) {
|
||||
badge.classList.add('bg-danger');
|
||||
} else {
|
||||
badge.classList.add('bg-secondary');
|
||||
}
|
||||
});
|
||||
|
||||
// Version breakdown summary
|
||||
var breakdown = document.getElementById('versionBreakdown');
|
||||
if (breakdown && sorted.length) {
|
||||
var html = '<ul class="list-unstyled mb-0">';
|
||||
sorted.forEach(function (v) {
|
||||
var cls = (v === latest) ? 'text-success fw-semibold' : 'text-danger';
|
||||
html += '<li class="d-flex justify-content-between small mb-1">'
|
||||
+ '<span class="' + cls + '">' + v + '</span>'
|
||||
+ '<span class="badge bg-secondary rounded-pill">' + freq[v] + ' device' + (freq[v] === 1 ? '' : 's') + '</span>'
|
||||
+ '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
breakdown.innerHTML = html;
|
||||
}
|
||||
|
||||
function compareSemver(a, b) {
|
||||
var pa = a.split('.').map(Number);
|
||||
var pb = b.split('.').map(Number);
|
||||
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
var diff = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -208,6 +208,12 @@
|
||||
<i class="bi bi-megaphone-fill"></i> Broadcast
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||
href="{{ url_for('devices.index') }}">
|
||||
<i class="bi bi-tablet"></i> Devices
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav align-items-center">
|
||||
|
||||
Reference in New Issue
Block a user