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
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>
791 lines
25 KiB
HTML
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 %} |