feat: Enhance status dashboard with SSL, deploy, security and API metrics
- Add load average display (1/5/15 min) - Add SSL certificate monitoring (expiry, issuer, days left) - Add Git/deploy info (branch, commit, date) - Add extended DB metrics (cache hit ratio, slow queries, deadlocks) - Add security metrics (failed logins, GeoIP blocks, rate limits, locked accounts) - Add external APIs status with latency (Google, Gemini, Gravatar) - Add servers ping monitoring (R11-REVPROXY-01, NORDABIZ-01, R11-DNS-01) - Fix pgrep path for Gunicorn worker detection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
edcb755588
commit
fca0e9d51e
@ -131,12 +131,73 @@ def admin_status():
|
||||
|
||||
# System uptime
|
||||
try:
|
||||
result = subprocess.run(['uptime'], capture_output=True, text=True, timeout=5)
|
||||
result = subprocess.run(['/usr/bin/uptime'], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
system_metrics['uptime'] = result.stdout.strip().split('up')[1].split(',')[0].strip()
|
||||
except Exception:
|
||||
system_metrics['uptime'] = None
|
||||
|
||||
# Load average
|
||||
try:
|
||||
result = subprocess.run(['/usr/bin/uptime'], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
# Parse: "load average: 0.52, 0.58, 0.59"
|
||||
load_part = result.stdout.split('load average:')[1].strip()
|
||||
loads = [float(x.strip()) for x in load_part.split(',')]
|
||||
system_metrics['load_1'] = loads[0]
|
||||
system_metrics['load_5'] = loads[1]
|
||||
system_metrics['load_15'] = loads[2]
|
||||
except Exception:
|
||||
system_metrics['load_1'] = None
|
||||
|
||||
# ===== SSL CERTIFICATE =====
|
||||
ssl_metrics = {}
|
||||
try:
|
||||
import ssl
|
||||
import socket
|
||||
context = ssl.create_default_context()
|
||||
with socket.create_connection(('nordabiznes.pl', 443), timeout=5) as sock:
|
||||
with context.wrap_socket(sock, server_hostname='nordabiznes.pl') as ssock:
|
||||
cert = ssock.getpeercert()
|
||||
not_after = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
|
||||
ssl_metrics['domain'] = 'nordabiznes.pl'
|
||||
ssl_metrics['expires'] = not_after.strftime('%Y-%m-%d')
|
||||
ssl_metrics['days_left'] = (not_after - datetime.now()).days
|
||||
ssl_metrics['issuer'] = dict(x[0] for x in cert['issuer']).get('organizationName', 'Unknown')
|
||||
ssl_metrics['status'] = 'ok' if ssl_metrics['days_left'] > 14 else 'warning' if ssl_metrics['days_left'] > 0 else 'expired'
|
||||
except Exception as e:
|
||||
ssl_metrics['status'] = 'error'
|
||||
ssl_metrics['error'] = str(e)[:50]
|
||||
|
||||
# ===== GIT/DEPLOY INFO =====
|
||||
deploy_metrics = {}
|
||||
try:
|
||||
# Current commit
|
||||
result = subprocess.run(['/usr/bin/git', 'rev-parse', '--short', 'HEAD'],
|
||||
capture_output=True, text=True, timeout=5, cwd='/var/www/nordabiznes')
|
||||
if result.returncode == 0:
|
||||
deploy_metrics['commit'] = result.stdout.strip()
|
||||
|
||||
# Commit date
|
||||
result = subprocess.run(['/usr/bin/git', 'log', '-1', '--format=%ci'],
|
||||
capture_output=True, text=True, timeout=5, cwd='/var/www/nordabiznes')
|
||||
if result.returncode == 0:
|
||||
deploy_metrics['commit_date'] = result.stdout.strip()[:16]
|
||||
|
||||
# Commit message (first line)
|
||||
result = subprocess.run(['/usr/bin/git', 'log', '-1', '--format=%s'],
|
||||
capture_output=True, text=True, timeout=5, cwd='/var/www/nordabiznes')
|
||||
if result.returncode == 0:
|
||||
deploy_metrics['commit_message'] = result.stdout.strip()[:60]
|
||||
|
||||
# Branch
|
||||
result = subprocess.run(['/usr/bin/git', 'branch', '--show-current'],
|
||||
capture_output=True, text=True, timeout=5, cwd='/var/www/nordabiznes')
|
||||
if result.returncode == 0:
|
||||
deploy_metrics['branch'] = result.stdout.strip()
|
||||
except Exception as e:
|
||||
deploy_metrics['error'] = str(e)[:50]
|
||||
|
||||
# ===== DATABASE METRICS =====
|
||||
db_metrics = {}
|
||||
|
||||
@ -179,6 +240,39 @@ def admin_status():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cache hit ratio
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT
|
||||
sum(heap_blks_hit) as hits,
|
||||
sum(heap_blks_read) as reads
|
||||
FROM pg_statio_user_tables
|
||||
""")).fetchone()
|
||||
if result and result[0] and (result[0] + result[1]) > 0:
|
||||
db_metrics['cache_hit_ratio'] = round(100 * result[0] / (result[0] + result[1]), 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Slow queries (queries over 1 second in last 24h)
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT count(*) FROM pg_stat_statements
|
||||
WHERE mean_exec_time > 1000
|
||||
""")).scalar()
|
||||
db_metrics['slow_queries'] = result or 0
|
||||
except Exception:
|
||||
db_metrics['slow_queries'] = 'N/A'
|
||||
|
||||
# Deadlocks
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT deadlocks FROM pg_stat_database
|
||||
WHERE datname = current_database()
|
||||
""")).scalar()
|
||||
db_metrics['deadlocks'] = result or 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db_metrics['status'] = 'ok'
|
||||
except Exception as e:
|
||||
db_metrics['status'] = 'error'
|
||||
@ -229,6 +323,53 @@ def admin_status():
|
||||
except Exception:
|
||||
app_metrics['alerts_24h'] = 0
|
||||
|
||||
# ===== SECURITY METRICS =====
|
||||
security_metrics = {}
|
||||
|
||||
# Failed logins (24h)
|
||||
try:
|
||||
security_metrics['failed_logins_24h'] = db.query(AuditLog).filter(
|
||||
AuditLog.action == 'login_failed',
|
||||
AuditLog.created_at >= yesterday
|
||||
).count()
|
||||
except Exception:
|
||||
security_metrics['failed_logins_24h'] = 0
|
||||
|
||||
# Blocked by GeoIP (24h)
|
||||
try:
|
||||
security_metrics['geoip_blocked_24h'] = db.query(SecurityAlert).filter(
|
||||
SecurityAlert.alert_type == 'geoip_blocked',
|
||||
SecurityAlert.created_at >= yesterday
|
||||
).count()
|
||||
except Exception:
|
||||
security_metrics['geoip_blocked_24h'] = 0
|
||||
|
||||
# Rate limit hits (24h)
|
||||
try:
|
||||
security_metrics['rate_limit_24h'] = db.query(SecurityAlert).filter(
|
||||
SecurityAlert.alert_type == 'rate_limit',
|
||||
SecurityAlert.created_at >= yesterday
|
||||
).count()
|
||||
except Exception:
|
||||
security_metrics['rate_limit_24h'] = 0
|
||||
|
||||
# Brute force blocked accounts
|
||||
try:
|
||||
security_metrics['locked_accounts'] = db.query(User).filter(
|
||||
User.is_locked == True
|
||||
).count()
|
||||
except Exception:
|
||||
security_metrics['locked_accounts'] = 0
|
||||
|
||||
# Unique IPs blocked (24h)
|
||||
try:
|
||||
result = db.query(func.count(func.distinct(SecurityAlert.ip_address))).filter(
|
||||
SecurityAlert.created_at >= yesterday
|
||||
).scalar()
|
||||
security_metrics['unique_ips_blocked'] = result or 0
|
||||
except Exception:
|
||||
security_metrics['unique_ips_blocked'] = 0
|
||||
|
||||
# ===== GUNICORN/PROCESS METRICS =====
|
||||
process_metrics = {}
|
||||
try:
|
||||
@ -244,6 +385,66 @@ def admin_status():
|
||||
logger.error(f"pgrep gunicorn exception: {e}")
|
||||
process_metrics['gunicorn_status'] = 'unknown'
|
||||
|
||||
# ===== EXTERNAL APIS STATUS =====
|
||||
external_apis = []
|
||||
import urllib.request
|
||||
import time as time_module
|
||||
|
||||
# Gemini API
|
||||
try:
|
||||
start = time_module.time()
|
||||
req = urllib.request.Request('https://generativelanguage.googleapis.com/', method='HEAD')
|
||||
req.add_header('User-Agent', 'NordaBiz-HealthCheck/1.0')
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
latency = round((time_module.time() - start) * 1000)
|
||||
external_apis.append({'name': 'Google Gemini', 'status': 'ok', 'latency': latency})
|
||||
except Exception:
|
||||
external_apis.append({'name': 'Google Gemini', 'status': 'error', 'latency': None})
|
||||
|
||||
# Brave Search API
|
||||
try:
|
||||
start = time_module.time()
|
||||
req = urllib.request.Request('https://api.search.brave.com/', method='HEAD')
|
||||
req.add_header('User-Agent', 'NordaBiz-HealthCheck/1.0')
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
latency = round((time_module.time() - start) * 1000)
|
||||
external_apis.append({'name': 'Brave Search', 'status': 'ok', 'latency': latency})
|
||||
except Exception:
|
||||
external_apis.append({'name': 'Brave Search', 'status': 'error', 'latency': None})
|
||||
|
||||
# Google PageSpeed API
|
||||
try:
|
||||
start = time_module.time()
|
||||
req = urllib.request.Request('https://pagespeedonline.googleapis.com/', method='HEAD')
|
||||
req.add_header('User-Agent', 'NordaBiz-HealthCheck/1.0')
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
latency = round((time_module.time() - start) * 1000)
|
||||
external_apis.append({'name': 'PageSpeed API', 'status': 'ok', 'latency': latency})
|
||||
except Exception:
|
||||
external_apis.append({'name': 'PageSpeed API', 'status': 'error', 'latency': None})
|
||||
|
||||
# ===== SERVERS PING =====
|
||||
servers_status = []
|
||||
servers_to_ping = [
|
||||
('NORDABIZ-01', '10.22.68.249'),
|
||||
('R11-REVPROXY-01', '10.22.68.250'),
|
||||
('R11-DNS-01', '10.22.68.171'),
|
||||
('R11-GIT-INPI', '10.22.68.180'),
|
||||
]
|
||||
|
||||
for name, ip in servers_to_ping:
|
||||
try:
|
||||
start = time_module.time()
|
||||
result = subprocess.run(['/usr/bin/ping', '-c', '1', '-W', '2', ip],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
latency = round((time_module.time() - start) * 1000)
|
||||
if result.returncode == 0:
|
||||
servers_status.append({'name': name, 'ip': ip, 'status': 'online', 'latency': latency})
|
||||
else:
|
||||
servers_status.append({'name': name, 'ip': ip, 'status': 'offline', 'latency': None})
|
||||
except Exception:
|
||||
servers_status.append({'name': name, 'ip': ip, 'status': 'unknown', 'latency': None})
|
||||
|
||||
# ===== TECHNOLOGY STACK =====
|
||||
import flask
|
||||
import sqlalchemy
|
||||
@ -303,6 +504,11 @@ def admin_status():
|
||||
app_metrics=app_metrics,
|
||||
process_metrics=process_metrics,
|
||||
technology_stack=technology_stack,
|
||||
ssl_metrics=ssl_metrics,
|
||||
deploy_metrics=deploy_metrics,
|
||||
security_metrics=security_metrics,
|
||||
external_apis=external_apis,
|
||||
servers_status=servers_status,
|
||||
generated_at=now
|
||||
)
|
||||
finally:
|
||||
|
||||
@ -445,6 +445,12 @@
|
||||
<span class="info-value">{{ system_metrics.uptime }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if system_metrics.load_1 is defined %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Load Average</span>
|
||||
<span class="info-value">{{ system_metrics.load_1 }} / {{ system_metrics.load_5 }} / {{ system_metrics.load_15 }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Database Metrics Card -->
|
||||
@ -500,6 +506,27 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if db_metrics.cache_hit_ratio is defined %}
|
||||
<div style="margin-top: var(--spacing-md); padding-top: var(--spacing-md); border-top: 1px solid var(--border);">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Cache Hit Ratio</span>
|
||||
<span class="info-value" style="color: {{ '#22c55e' if db_metrics.cache_hit_ratio > 95 else '#f59e0b' if db_metrics.cache_hit_ratio > 80 else '#ef4444' }};">{{ db_metrics.cache_hit_ratio }}%</span>
|
||||
</div>
|
||||
{% if db_metrics.slow_queries is defined %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Slow Queries (>1s)</span>
|
||||
<span class="info-value">{{ db_metrics.slow_queries }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if db_metrics.deadlocks is defined %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Deadlocks</span>
|
||||
<span class="info-value" style="color: {{ '#22c55e' if db_metrics.deadlocks == 0 else '#ef4444' }};">{{ db_metrics.deadlocks }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div style="padding: var(--spacing-xl); text-align: center; color: #991b1b;">
|
||||
<p>⚠️ Błąd połączenia z bazą danych</p>
|
||||
@ -613,6 +640,182 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Metrics Row -->
|
||||
<div class="metrics-grid" style="margin-bottom: var(--spacing-xl);">
|
||||
<!-- SSL Certificate Card -->
|
||||
{% if ssl_metrics is defined %}
|
||||
<div class="metric-card" style="border-top: 4px solid {{ '#22c55e' if ssl_metrics.status == 'ok' else '#f59e0b' if ssl_metrics.status == 'warning' else '#ef4444' }};">
|
||||
<div class="metric-card-header">
|
||||
<div class="metric-card-icon" style="background: linear-gradient(135deg, #dcfce7, #d1fae5);">🔒</div>
|
||||
<div>
|
||||
<div class="metric-card-title">Certyfikat SSL</div>
|
||||
<div class="metric-card-subtitle">{{ ssl_metrics.domain if ssl_metrics.domain else 'nordabiznes.pl' }}</div>
|
||||
</div>
|
||||
<span class="status-badge {{ ssl_metrics.status }}">
|
||||
{% if ssl_metrics.status == 'ok' %}✓ Ważny
|
||||
{% elif ssl_metrics.status == 'warning' %}⚠️ Wkrótce wygasa
|
||||
{% else %}✗ Problem{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if ssl_metrics.days_left is defined %}
|
||||
<div class="metric-stats" style="grid-template-columns: 1fr 1fr;">
|
||||
<div class="metric-stat">
|
||||
<div class="metric-stat-value" style="color: {{ '#22c55e' if ssl_metrics.days_left > 30 else '#f59e0b' if ssl_metrics.days_left > 7 else '#ef4444' }};">{{ ssl_metrics.days_left }}</div>
|
||||
<div class="metric-stat-label">Dni do wygaśnięcia</div>
|
||||
</div>
|
||||
<div class="metric-stat">
|
||||
<div class="metric-stat-value" style="font-size: var(--font-size-sm);">{{ ssl_metrics.expires }}</div>
|
||||
<div class="metric-stat-label">Data wygaśnięcia</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row" style="margin-top: var(--spacing-md);">
|
||||
<span class="info-label">Wystawca</span>
|
||||
<span class="info-value">{{ ssl_metrics.issuer }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: var(--spacing-lg); text-align: center; color: #991b1b;">
|
||||
<p>⚠️ Nie można sprawdzić certyfikatu</p>
|
||||
{% if ssl_metrics.error %}<p style="font-size: var(--font-size-xs);">{{ ssl_metrics.error }}</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Deploy Info Card -->
|
||||
{% if deploy_metrics is defined %}
|
||||
<div class="metric-card" style="border-top: 4px solid #8b5cf6;">
|
||||
<div class="metric-card-header">
|
||||
<div class="metric-card-icon" style="background: linear-gradient(135deg, #ede9fe, #ddd6fe);">🚀</div>
|
||||
<div>
|
||||
<div class="metric-card-title">Deploy</div>
|
||||
<div class="metric-card-subtitle">Git / Wersja</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if deploy_metrics.commit %}
|
||||
<div class="metric-stats" style="grid-template-columns: 1fr;">
|
||||
<div class="metric-stat">
|
||||
<div class="metric-stat-value" style="font-family: monospace; font-size: var(--font-size-lg);">{{ deploy_metrics.commit }}</div>
|
||||
<div class="metric-stat-label">Aktualny commit</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: var(--spacing-md);">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Branch</span>
|
||||
<span class="info-value">{{ deploy_metrics.branch }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Data commitu</span>
|
||||
<span class="info-value">{{ deploy_metrics.commit_date }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Opis</span>
|
||||
<span class="info-value" style="font-size: var(--font-size-xs); max-width: 200px; overflow: hidden; text-overflow: ellipsis;">{{ deploy_metrics.commit_message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: var(--spacing-lg); text-align: center; color: var(--text-secondary);">
|
||||
<p>Brak danych Git</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Security Metrics Card -->
|
||||
{% if security_metrics is defined %}
|
||||
<div class="metric-card" style="border-top: 4px solid #ef4444;">
|
||||
<div class="metric-card-header">
|
||||
<div class="metric-card-icon" style="background: linear-gradient(135deg, #fee2e2, #fecaca);">🛡️</div>
|
||||
<div>
|
||||
<div class="metric-card-title">Bezpieczeństwo</div>
|
||||
<div class="metric-card-subtitle">Ostatnie 24h</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-stats">
|
||||
<div class="metric-stat">
|
||||
<div class="metric-stat-value" style="color: {{ '#22c55e' if security_metrics.failed_logins_24h == 0 else '#f59e0b' if security_metrics.failed_logins_24h < 10 else '#ef4444' }};">{{ security_metrics.failed_logins_24h }}</div>
|
||||
<div class="metric-stat-label">Nieudane logowania</div>
|
||||
</div>
|
||||
<div class="metric-stat">
|
||||
<div class="metric-stat-value">{{ security_metrics.geoip_blocked_24h }}</div>
|
||||
<div class="metric-stat-label">Blokady GeoIP</div>
|
||||
</div>
|
||||
<div class="metric-stat">
|
||||
<div class="metric-stat-value">{{ security_metrics.rate_limit_24h }}</div>
|
||||
<div class="metric-stat-label">Rate limit hits</div>
|
||||
</div>
|
||||
<div class="metric-stat">
|
||||
<div class="metric-stat-value" style="color: {{ '#22c55e' if security_metrics.locked_accounts == 0 else '#ef4444' }};">{{ security_metrics.locked_accounts }}</div>
|
||||
<div class="metric-stat-label">Zablokowane konta</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row" style="margin-top: var(--spacing-md);">
|
||||
<span class="info-label">Unikalne IP zablokowane</span>
|
||||
<span class="info-value">{{ security_metrics.unique_ips_blocked }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- External APIs Status Card -->
|
||||
{% if external_apis is defined %}
|
||||
<div class="metric-card" style="border-top: 4px solid #06b6d4;">
|
||||
<div class="metric-card-header">
|
||||
<div class="metric-card-icon" style="background: linear-gradient(135deg, #cffafe, #a5f3fc);">🔌</div>
|
||||
<div>
|
||||
<div class="metric-card-title">External APIs</div>
|
||||
<div class="metric-card-subtitle">Status połączeń</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
|
||||
{% for api in external_apis %}
|
||||
<div style="display: flex; align-items: center; gap: var(--spacing-md); padding: var(--spacing-sm); background: var(--background); border-radius: var(--radius);">
|
||||
<div class="health-dot {{ api.status }}"></div>
|
||||
<span style="flex: 1; font-weight: 500;">{{ api.name }}</span>
|
||||
{% if api.latency %}
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ api.latency }}ms</span>
|
||||
{% else %}
|
||||
<span style="font-size: var(--font-size-xs); color: #ef4444;">offline</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Servers Status Row -->
|
||||
{% if servers_status is defined %}
|
||||
<div class="metric-card" style="margin-bottom: var(--spacing-xl); border-top: 4px solid #6366f1;">
|
||||
<div class="metric-card-header">
|
||||
<div class="metric-card-icon" style="background: linear-gradient(135deg, #e0e7ff, #c7d2fe);">🖥️</div>
|
||||
<div>
|
||||
<div class="metric-card-title">Status serwerów</div>
|
||||
<div class="metric-card-subtitle">Ping w sieci INPI</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--spacing-md);">
|
||||
{% for server in servers_status %}
|
||||
<div style="display: flex; align-items: center; gap: var(--spacing-md); padding: var(--spacing-md); background: var(--background); border-radius: var(--radius); border-left: 3px solid {{ '#22c55e' if server.status == 'online' else '#ef4444' }};">
|
||||
<div>
|
||||
<div class="health-dot {{ 'ok' if server.status == 'online' else 'error' }}" style="width: 12px; height: 12px;"></div>
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600;">{{ server.name }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary); font-family: monospace;">{{ server.ip }}</div>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
{% if server.status == 'online' %}
|
||||
<div style="font-size: var(--font-size-sm); color: #22c55e;">Online</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ server.latency }}ms</div>
|
||||
{% else %}
|
||||
<div style="font-size: var(--font-size-sm); color: #ef4444;">Offline</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Technology Stack Section -->
|
||||
<div class="tech-stack-section" style="margin-top: var(--spacing-2xl);">
|
||||
<h2 style="font-size: var(--font-size-xl); color: var(--text-primary); margin-bottom: var(--spacing-lg);">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user