nordabiz/templates/admin/uptime_dashboard.html
Maciej Pienczyn 9540f7f2e0
Some checks are pending
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat: add uptime monitoring dashboard with UptimeRobot integration
External monitoring via UptimeRobot (free tier) with internal health
logger to differentiate ISP outages from server issues. Includes:
- 4 new DB models (UptimeMonitor, UptimeCheck, UptimeIncident, InternalHealthLog)
- Migration 082 with tables, indexes, and permissions
- Internal health logger script (cron */5 min)
- UptimeRobot sync script (cron hourly) with automatic cause correlation
- Admin dashboard /admin/uptime with uptime %, response time charts,
  incident log with editable notes/causes, pattern analysis, monthly report
- SLA comparison table (99.9%/99.5%/99%)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 07:53:05 +01:00

791 lines
25 KiB
HTML

{% extends "base.html" %}
{% block title %}Monitoring uptime - Admin{% endblock %}
{% block extra_css %}
<style>
.uptime-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
}
.uptime-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.uptime-header p {
color: var(--text-secondary);
}
.refresh-info {
text-align: right;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-md) var(--spacing-lg);
}
.refresh-info .timestamp {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
font-family: monospace;
}
.refresh-info .label {
font-size: var(--font-size-xs);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Status badge */
.status-badge-large {
display: inline-flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-lg) var(--spacing-xl);
border-radius: var(--radius-xl);
font-size: var(--font-size-xl);
font-weight: 700;
}
.status-badge-large.up {
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
color: #166534;
border: 2px solid #86efac;
}
.status-badge-large.down {
background: linear-gradient(135deg, #fee2e2, #fecaca);
color: #991b1b;
border: 2px solid #fca5a5;
animation: pulse-red 2s infinite;
}
.status-badge-large.unknown {
background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
color: #6b7280;
border: 2px solid #d1d5db;
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
.status-dot {
width: 16px;
height: 16px;
border-radius: 50%;
}
.status-dot.up { background: #22c55e; }
.status-dot.down { background: #ef4444; animation: pulse 2s infinite; }
.status-dot.unknown { background: #9ca3af; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: 400;
margin-top: var(--spacing-xs);
}
/* Uptime cards */
.uptime-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
@media (max-width: 768px) {
.uptime-cards { grid-template-columns: repeat(2, 1fr); }
}
.uptime-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--spacing-xl);
text-align: center;
}
.uptime-card .period {
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--spacing-sm);
}
.uptime-card .value {
font-size: var(--font-size-3xl);
font-weight: 800;
font-family: monospace;
}
.uptime-card .value.green { color: #16a34a; }
.uptime-card .value.yellow { color: #ca8a04; }
.uptime-card .value.red { color: #dc2626; }
.uptime-card .detail {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
/* Sections */
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
.section-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.section-title svg {
width: 20px;
height: 20px;
color: var(--primary);
}
/* Chart */
.chart-controls {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.chart-btn {
padding: var(--spacing-xs) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-size-sm);
}
.chart-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.chart-container {
position: relative;
height: 300px;
}
/* Incidents table */
.incidents-table {
width: 100%;
border-collapse: collapse;
}
.incidents-table th {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 2px solid var(--border);
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: 600;
}
.incidents-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border);
font-size: var(--font-size-sm);
}
.cause-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 600;
}
.cause-badge.isp { background: #fef3c7; color: #92400e; }
.cause-badge.server { background: #fee2e2; color: #991b1b; }
.cause-badge.infra { background: #ede9fe; color: #5b21b6; }
.cause-badge.unknown { background: #f3f4f6; color: #6b7280; }
.notes-input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-xs);
background: var(--surface);
color: var(--text-primary);
}
.notes-input:focus {
outline: none;
border-color: var(--primary);
}
.cause-select {
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-xs);
background: var(--surface);
color: var(--text-primary);
}
/* Patterns grid */
.patterns-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
}
@media (max-width: 768px) {
.patterns-grid { grid-template-columns: 1fr; }
}
.pattern-chart {
height: 200px;
}
/* Monthly report */
.report-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
}
.report-stat {
text-align: center;
padding: var(--spacing-lg);
background: var(--background);
border-radius: var(--radius-lg);
}
.report-stat .stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-primary);
}
.report-stat .stat-label {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.trend-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 600;
}
.trend-badge.better { background: #dcfce7; color: #166534; }
.trend-badge.worse { background: #fee2e2; color: #991b1b; }
.trend-badge.same { background: #f3f4f6; color: #6b7280; }
/* SLA table */
.sla-table {
width: 100%;
border-collapse: collapse;
margin-top: var(--spacing-md);
}
.sla-table th, .sla-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border);
font-size: var(--font-size-sm);
text-align: center;
}
.sla-table th {
color: var(--text-secondary);
font-weight: 600;
}
.sla-current {
background: var(--primary-light, #eff6ff);
font-weight: 600;
}
/* No data state */
.no-data {
text-align: center;
padding: var(--spacing-3xl);
color: var(--text-secondary);
}
.no-data svg {
width: 64px;
height: 64px;
margin-bottom: var(--spacing-lg);
opacity: 0.3;
}
.no-data h3 {
font-size: var(--font-size-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.setup-steps {
text-align: left;
max-width: 500px;
margin: var(--spacing-lg) auto;
}
.setup-steps li {
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.setup-steps code {
background: var(--background);
padding: 2px 6px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
}
</style>
{% endblock %}
{% block content %}
<div class="uptime-header">
<div>
<h1>Monitoring uptime</h1>
<p>Dostepnosc portalu nordabiznes.pl z perspektywy uzytkownikow zewnetrznych</p>
</div>
<div class="refresh-info">
<div class="label">Ostatnia aktualizacja</div>
<div class="timestamp" id="refresh-time">{{ now.strftime('%H:%M:%S') if now is defined else '--:--:--' }}</div>
</div>
</div>
{% if not data.has_data %}
<!-- Brak danych — instrukcja konfiguracji -->
<div class="section">
<div class="no-data">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3>Monitoring nie jest jeszcze skonfigurowany</h3>
<p>Aby uruchomic monitoring, wykonaj ponizsza konfiguracje:</p>
<ol class="setup-steps">
<li>Zaloz konto na <strong>uptimerobot.com</strong> (darmowy plan)</li>
<li>Dodaj monitor: HTTPS, URL <code>https://nordabiznes.pl/health</code>, interwal 5 min</li>
<li>Skopiuj <strong>Main API Key</strong> z ustawien konta</li>
<li>Dodaj do <code>.env</code>: <code>UPTIMEROBOT_API_KEY=twoj_klucz</code></li>
<li>Dodaj cron jobs na serwerze:
<br><code>*/5 * * * * cd /var/www/nordabiznes && ...</code> (health logger)
<br><code>0 * * * * cd /var/www/nordabiznes && ...</code> (UptimeRobot sync)
</li>
</ol>
</div>
</div>
{% else %}
<!-- 1. Aktualny status -->
<div style="margin-bottom: var(--spacing-xl);">
<div class="status-badge-large {{ data.current_status }}">
<div class="status-dot {{ data.current_status }}"></div>
{% if data.current_status == 'up' %}
Portal dziala poprawnie
{% elif data.current_status == 'down' %}
Portal niedostepny!
{% else %}
Status nieznany
{% endif %}
</div>
<div class="status-meta">
{% if data.last_checked %}
Ostatnie sprawdzenie: {{ data.last_checked }}
{% if data.last_response_time %} | Czas odpowiedzi: {{ data.last_response_time }}ms{% endif %}
{% endif %}
| Monitor: {{ data.monitor.name }} ({{ data.monitor.url }})
</div>
</div>
<!-- 2. Uptime karty -->
<div class="uptime-cards">
{% for period, label in [('24h', 'Ostatnie 24h'), ('7d', 'Ostatnie 7 dni'), ('30d', 'Ostatnie 30 dni'), ('90d', 'Ostatnie 90 dni')] %}
<div class="uptime-card">
<div class="period">{{ label }}</div>
{% if data.uptime[period].percent is not none %}
{% set pct = data.uptime[period].percent %}
<div class="value {% if pct >= 99.9 %}green{% elif pct >= 99.5 %}yellow{% else %}red{% endif %}">
{{ '%.2f' % pct }}%
</div>
<div class="detail">
{{ data.uptime[period].down_checks }} awarii / {{ data.uptime[period].total_checks }} sprawdzen
</div>
{% else %}
<div class="value" style="color: var(--text-secondary);">--</div>
<div class="detail">Brak danych</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- 3. Wykres response time -->
<div class="section">
<div class="section-title">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
Czas odpowiedzi
{% if data.avg_response_time %}
<span style="font-weight: 400; font-size: var(--font-size-sm); color: var(--text-secondary);">
(sredni: {{ data.avg_response_time }}ms)
</span>
{% endif %}
</div>
<div class="chart-controls">
<button class="chart-btn active" data-days="1" onclick="changeChartPeriod(1)">24h</button>
<button class="chart-btn" data-days="7" onclick="changeChartPeriod(7)">7 dni</button>
<button class="chart-btn" data-days="30" onclick="changeChartPeriod(30)">30 dni</button>
</div>
<div class="chart-container">
<canvas id="responseTimeChart"></canvas>
</div>
</div>
<!-- 4. Lista incydentow -->
<div class="section">
<div class="section-title">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
Incydenty ({{ data.incidents|length }})
</div>
{% if data.incidents %}
<div style="overflow-x: auto;">
<table class="incidents-table">
<thead>
<tr>
<th>Data</th>
<th>Czas trwania</th>
<th>Przyczyna</th>
<th>Notatki</th>
</tr>
</thead>
<tbody>
{% for inc in data.incidents %}
<tr>
<td>
{{ inc.started_at }}
{% if inc.ended_at %}<br><span style="color: var(--text-secondary); font-size: var(--font-size-xs);">do {{ inc.ended_at }}</span>{% endif %}
</td>
<td><strong>{{ inc.duration_human }}</strong></td>
<td>
<select class="cause-select" data-incident-id="{{ inc.id }}" onchange="updateIncident(this)">
<option value="isp" {% if inc.cause == 'isp' %}selected{% endif %}>ISP (Chopin)</option>
<option value="server" {% if inc.cause == 'server' %}selected{% endif %}>Serwer</option>
<option value="infra" {% if inc.cause == 'infra' %}selected{% endif %}>Infrastruktura</option>
<option value="unknown" {% if inc.cause == 'unknown' %}selected{% endif %}>Nieznana</option>
</select>
</td>
<td>
<input type="text" class="notes-input" data-incident-id="{{ inc.id }}"
value="{{ inc.notes }}" placeholder="Dodaj notatke..."
onblur="updateIncidentNotes(this)">
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color: var(--text-secondary); text-align: center; padding: var(--spacing-xl);">
Brak zarejestrowanych incydentow
</p>
{% endif %}
</div>
<!-- 5. Analiza wzorcow -->
<div class="section">
<div class="section-title">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Wzorce awarii
</div>
{% if data.incidents %}
<div class="patterns-grid">
<div>
<h4 style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-sm);">Awarie wg godziny</h4>
<div class="pattern-chart">
<canvas id="hourChart"></canvas>
</div>
</div>
<div>
<h4 style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-sm);">Awarie wg dnia tygodnia</h4>
<div class="pattern-chart">
<canvas id="dowChart"></canvas>
</div>
</div>
</div>
{% else %}
<p style="color: var(--text-secondary); text-align: center;">Brak danych do analizy wzorcow</p>
{% endif %}
</div>
<!-- 6. Raport miesieczny -->
<div class="section">
<div class="section-title">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Raport miesieczny: {{ data.monthly_report.month }}
</div>
<div class="report-grid">
<div class="report-stat">
<div class="stat-value {% if data.monthly_report.uptime_pct >= 99.9 %}green{% elif data.monthly_report.uptime_pct >= 99.5 %}yellow{% else %}red{% endif %}" style="color: {% if data.monthly_report.uptime_pct >= 99.9 %}#16a34a{% elif data.monthly_report.uptime_pct >= 99.5 %}#ca8a04{% else %}#dc2626{% endif %}">
{{ '%.3f' % data.monthly_report.uptime_pct }}%
</div>
<div class="stat-label">Uptime SLA</div>
</div>
<div class="report-stat">
<div class="stat-value">{{ data.monthly_report.total_downtime_human }}</div>
<div class="stat-label">Laczny przestoj</div>
</div>
<div class="report-stat">
<div class="stat-value">{{ data.monthly_report.incidents_count }}</div>
<div class="stat-label">Liczba incydentow</div>
</div>
<div class="report-stat">
<div class="stat-value">{{ data.monthly_report.longest_incident }}</div>
<div class="stat-label">Najdluzszy incydent</div>
</div>
</div>
<!-- Porownanie z poprzednim miesiacem -->
<div style="margin-top: var(--spacing-lg); padding: var(--spacing-md); background: var(--background); border-radius: var(--radius-lg);">
<strong>Trend vs {{ data.monthly_report.prev_month }}:</strong>
<span class="trend-badge {{ data.monthly_report.trend }}">
{% if data.monthly_report.trend == 'better' %}Lepiej
{% elif data.monthly_report.trend == 'worse' %}Gorzej
{% else %}Bez zmian{% endif %}
</span>
(poprzednio: {{ data.monthly_report.prev_downtime_human }} przestoju, {{ data.monthly_report.prev_incidents_count }} incydentow)
</div>
<!-- Tabela SLA -->
<table class="sla-table">
<thead>
<tr>
<th>Poziom SLA</th>
<th>Max przestoj / miesiac</th>
<th>Max przestoj / rok</th>
<th>Twoj status</th>
</tr>
</thead>
<tbody>
{% for level, limits in data.sla_context.items() %}
<tr {% if data.monthly_report.uptime_pct >= level|float and (loop.last or data.monthly_report.uptime_pct < data.sla_context.keys()|list|sort|reverse|first|float if not loop.first else true) %}class="sla-current"{% endif %}>
<td><strong>{{ level }}%</strong></td>
<td>{{ limits.max_downtime_month }}</td>
<td>{{ limits.max_downtime_year }}</td>
<td>
{% if data.monthly_report.uptime_pct >= level|float %}
<span style="color: #16a34a;">Spelnia</span>
{% else %}
<span style="color: #dc2626;">Nie spelnia</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block head_extra %}
<script src="{{ url_for('static', filename='js/vendor/chart.min.js') }}"></script>
{% endblock %}
{% block extra_js %}
{% if data.has_data %}
// Dane z backendu
var responseTimesData = {{ data.response_times | tojson }};
var hourData = {{ data.patterns.by_hour | tojson }};
var dowData = {{ data.patterns.by_dow | tojson }};
// Response time chart
var rtCtx = document.getElementById('responseTimeChart');
var rtChart = null;
function renderResponseTimeChart(days) {
if (rtChart) rtChart.destroy();
var cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
var filtered = responseTimesData.filter(function(d) {
return new Date(d.time) >= cutoff;
});
var labels = filtered.map(function(d) { return d.time; });
var values = filtered.map(function(d) { return d.ms; });
rtChart = new Chart(rtCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Response time (ms)',
data: values,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
pointRadius: days <= 1 ? 3 : (days <= 7 ? 2 : 0),
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
display: true,
ticks: {
maxTicksLimit: 12,
font: { size: 10 }
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'ms'
}
}
}
}
});
}
function changeChartPeriod(days) {
document.querySelectorAll('.chart-btn').forEach(function(b) {
b.classList.toggle('active', parseInt(b.dataset.days) === days);
});
renderResponseTimeChart(days);
}
// Patterns charts
if (document.getElementById('hourChart')) {
new Chart(document.getElementById('hourChart'), {
type: 'bar',
data: {
labels: Array.from({length: 24}, function(_, i) { return i + ':00'; }),
datasets: [{
data: hourData,
backgroundColor: 'rgba(239, 68, 68, 0.6)',
borderColor: '#ef4444',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } },
x: { ticks: { font: { size: 9 } } }
}
}
});
}
if (document.getElementById('dowChart')) {
new Chart(document.getElementById('dowChart'), {
type: 'bar',
data: {
labels: ['Pon', 'Wt', 'Sr', 'Czw', 'Pt', 'Sob', 'Nie'],
datasets: [{
data: dowData,
backgroundColor: 'rgba(245, 158, 11, 0.6)',
borderColor: '#f59e0b',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
}
}
});
}
// Init
renderResponseTimeChart(1);
// CSRF token
var csrfToken = '{{ csrf_token() }}';
// Aktualizacja incydentow
function updateIncident(selectEl) {
var id = selectEl.dataset.incidentId;
var cause = selectEl.value;
fetch('/admin/api/uptime/incident/' + id + '/notes', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrfToken},
body: JSON.stringify({cause: cause})
});
}
function updateIncidentNotes(inputEl) {
var id = inputEl.dataset.incidentId;
var notes = inputEl.value;
fetch('/admin/api/uptime/incident/' + id + '/notes', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrfToken},
body: JSON.stringify({notes: notes})
});
}
// Auto-refresh co 5 min
setInterval(function() {
fetch('/admin/api/uptime')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
document.getElementById('refresh-time').textContent =
new Date(data.timestamp).toLocaleTimeString('pl-PL');
}
})
.catch(function() {});
}, 300000);
{% endif %}
{% endblock %}