This commit is contained in:
Mateusz Gruszczyński committed 2026-10-09 10:53:10 +02:00
1 parent ad620c976a
commit 0973129c17
26 files changed
+2255 -2245

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#131615">
<meta name="description" content="Convert public hosts and adblocking lists into MikroTik-compatible host entries.">
<title>{% block title %}Adlist Converter{% endblock %} / Control room</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg', v=asset_revision) }}">
<script>
try { const theme = localStorage.getItem('adlist-theme');
if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme;
} catch (_) {}
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css', v=asset_revision) }}">
{% block head %}{% endblock %}
</head>
<body>
<div class="shell">
<aside class="sidebar" id="sidebar">
<a class="brand" href="{{ url_for('index') }}" aria-label="Adlist Converter home">
<span class="brand-mark" aria-hidden="true">a<span>/</span>l</span>
<span class="brand-name">Adlist<span class="brand-suffix"> Converter</span></span>
</a>
<div class="edition">HOSTS CONVERSION ENGINE</div>
<nav class="main-navigation" aria-label="Main navigation" id="main-navigation">
<a class="nav {% if page == 'converter' %}active{% endif %}" href="{{ url_for('index') }}" {% if page == 'converter' %}aria-current="page"{% endif %}>
<span class="nav-index">01</span><b>Converter</b><span class="nav-arrow" aria-hidden="true">↗</span>
</a>
<a class="nav" href="{{ url_for('index') }}#recent-links">
<span class="nav-index">02</span><b>Recent links</b><span class="nav-arrow" aria-hidden="true">↗</span>
</a>
<a class="nav {% if page == 'stats' %}active{% endif %}" href="{{ url_for('stats') }}" {% if page == 'stats' %}aria-current="page"{% endif %}>
<span class="nav-index">03</span><b>Statistics</b><span class="nav-arrow" aria-hidden="true">↗</span>
</a>
<a class="nav" href="{{ url_for('stats_json') }}" target="_blank" rel="noopener">
<span class="nav-index">04</span><b>Stats API</b><span class="nav-arrow" aria-hidden="true">↗</span>
</a>
</nav>
<div class="sidebar-bottom">
<span class="small-label">RUNTIME</span>
<p>Python / Flask / Redis<br>WSGI / ASGI / HTTP cache</p>
<div class="local-tag"><i aria-hidden="true"></i> PROXY CACHE READY</div>
<div class="sidebar-credit"><a href="https://www.linuxiarz.pl" target="_blank" rel="noopener noreferrer">linuxiarz.pl <span aria-hidden="true">↗</span></a></div>
</div>
</aside>
<div class="workarea">
<header class="topbar">
<div class="topbar-left">
<button class="menu-toggle quiet" type="button" data-action="menu" aria-controls="main-navigation" aria-expanded="false" aria-label="Toggle menu">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<div class="breadcrumb">CONTROL ROOM <span>/</span> <b>{% if page == 'stats' %}Statistics{% elif page == 'error' %}Error{% else %}Converter{% endif %}</b></div>
</div>
<div class="top-actions">
<span class="top-annotation">HOSTS / MIKROTIK</span>
<button type="button" class="quiet theme-toggle" data-action="toggle-theme" aria-label="Switch color theme" title="Switch color theme">
<svg class="theme-icon theme-icon-sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="theme-icon theme-icon-moon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M20.5 14.1A8.5 8.5 0 0 1 9.9 3.5 8.5 8.5 0 1 0 20.5 14.1Z"/></svg>
<span>Theme</span>
</button>
<a class="quiet top-external" href="https://www.linuxiarz.pl" target="_blank" rel="noopener noreferrer">linuxiarz.pl <span aria-hidden="true">↗</span></a>
</div>
</header>
<main id="main-content">
{% block content %}{% endblock %}
</main>
<footer class="app-footer"><span>ADLIST CONVERTER <span class="footer-divide">/</span> INDEPENDENT UTILITY</span><span>Powered by Flask · Built for MikroTik</span></footer>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js', v=asset_revision) }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+13 -88
View File
@@ -1,88 +1,13 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Error {{ error.code or 500 }}</title>
<meta name="theme-color" content="#0f1115" />
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<header class="site-header">
<div class="brand">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
</svg>
<span>Hosts Converter</span>
</div>
<nav class="actions">
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
<a class="btn primary" href="/" rel="nofollow">Home</a>
</nav>
</header>
<main class="container">
<section class="card error-card">
<div class="error-hero">
<div class="error-illustration" aria-hidden="true">⚠️</div>
<div class="error-main">
<div class="status-badge">Error {{ error.code or 500 }}</div>
<h1 class="error-title">
{% if (error.code or 500) == 400 %}Bad request
{% elif (error.code or 500) == 403 %}Forbidden
{% elif (error.code or 500) == 404 %}Not found
{% elif (error.code or 500) == 413 %}Payload too large
{% elif (error.code or 500) == 415 %}Nieobs&#322;ugiwany format listy
{% elif (error.code or 500) == 422 %}Nie mo&#380;na przetworzy&#263; listy
{% elif (error.code or 500) == 502 %}Nie uda&#322;o si&#281; pobra&#263; listy
{% elif (error.code or 500) == 500 %}Internal server error
{% else %}Something went wrong
{% endif %}
</h1>
<p class="muted">{{ (error.description|string)|e }}</p>
<div class="error-actions">
<button class="btn" type="button" data-action="try-again">Try again</button>
<a class="btn outline" href="/" rel="nofollow">Go home</a>
</div>
</div>
</div>
<details class="error-details">
<summary>
<span class="summary-title">Error details</span>
<span class="summary-hint">click to expand</span>
</summary>
<div class="details-body">
<pre id="error-dump" class="mono">
code: {{ error.code or 500 }}
message: {{ (error.description|string) }}
path: {{ request.path if request else '/' }}
method: {{ request.method if request else 'GET' }}
user_ip: {{ request.remote_addr if request else '' }}
user_agent: {{ request.headers.get('User-Agent') if request else '' }}
</pre>
<div class="details-actions">
</div>
</div>
</details>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a></div>
<div class="meta">Your IP: <strong>{{ request.remote_addr if request else '' }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/error.js') }}"></script>
</body>
</html>
{% extends 'base.html' %}
{% set page = 'error' %}
{% block title %}Error {{ code }}{% endblock %}
{% block content %}
<section class="page-intro"><div><div class="eyebrow error-eyebrow">REQUEST / ERROR {{ code }}</div><h1>We couldn't <em>complete that.</em></h1><p class="lead">Your request could not be processed. Review the details below and try a different source if needed.</p></div></section>
<section class="card error-card">
<div class="error-head"><div class="error-icon" aria-hidden="true">!</div><div><span class="small-label">HTTP {{ code }} / REQUEST FAILED</span><h2>{% if code == 400 %}Invalid request{% elif code == 403 %}Access denied{% elif code == 404 %}Page not found{% elif code == 413 %}Source file too large{% elif code == 415 %}Unsupported file format{% elif code == 422 %}Unable to decode this list{% elif code == 429 %}Rate limit exceeded{% elif code == 502 %}Source unavailable{% elif code == 500 %}Internal server error{% else %}Unable to process request{% endif %}</h2></div></div>
<p class="error-description">{{ error.description }}</p>
<div class="error-actions"><a href="{{ url_for('index') }}" class="primary">Back to converter</a><button type="button" class="outline" data-action="try-again">Retry request</button></div>
<details class="error-details"><summary>Technical details <span aria-hidden="true">↓</span></summary><dl><div><dt>HTTP STATUS</dt><dd>{{ code }}</dd></div><div><dt>METHOD</dt><dd>{{ request.method }}</dd></div><div><dt>PATH</dt><dd>{{ request.path }}</dd></div></dl></details>
</section>
<div class="error-tip"><i class="signal" aria-hidden="true"></i><span>Invalid or failed conversion responses are not cached by the reverse proxy.</span></div>
{% endblock %}
+106 -151
View File
@@ -1,156 +1,111 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
{% extends 'base.html' %}
{% set page = 'converter' %}
{% block title %}Converter{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<div class="eyebrow"><i class="signal" aria-hidden="true"></i> THE CONVERSION WORKSPACE</div>
<h1>Blocklists, <em>made compatible.</em></h1>
<p class="lead">Turn public hosts, DNS and adblock sources into MikroTik-compatible lists. One source URL. One output link. Ready for your DNS setup.</p>
</div>
<div class="hero-identity" aria-hidden="true"><span>DNS</span><span class="hero-line"></span><strong>LIST</strong><small>/CONVERT</small></div>
</section>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hosts Converter</title>
<meta name="theme-color" content="#0f1115" />
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<header class="site-header">
<div class="brand">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
</svg>
<span>Hosts Converter</span>
</div>
<nav class="actions">
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
<a class="btn primary" href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a>
</nav>
<section class="workspace-grid" aria-label="Conversion tools">
<article class="card generator-card" id="generator">
<header class="card-heading">
<div><span class="small-label">01 / CONFIGURATION</span><h2>Link generator</h2></div>
<span class="pill live"><i class="signal" aria-hidden="true"></i> READY</span>
</header>
<form id="converter-form" novalidate>
<div class="field">
<label for="url-input">Source URL <span class="required">*</span></label>
<div class="input-wrap url-field">
<span class="input-prefix" aria-hidden="true">↗</span>
<input type="url" id="url-input" name="url" placeholder="https://example.com/blocklist.txt" inputmode="url" autocomplete="url" spellcheck="false" aria-describedby="url-help url-error" required>
</div>
<div class="field-hint" id="url-help">Paste a direct link to a plain-text hosts or adblock list.</div>
<p class="field-error" id="url-error" role="alert" hidden></p>
</div>
<div class="field">
<div class="label-row"><label for="ip-input">Target IP <span class="required">*</span></label><span class="fine">IPv4 / IPv6 supported</span></div>
<div class="preset-row" role="group" aria-label="Target IP presets">
<button type="button" class="preset" data-ip="0.0.0.0">0.0.0.0 <small>BLACKHOLE</small></button>
<button type="button" class="preset" data-ip="127.0.0.1">127.0.0.1 <small>LOOPBACK</small></button>
<button type="button" class="preset" data-ip="195.187.6.34">195.187.6.34 <small>DEFAULT</small></button>
</div>
<input id="ip-input" name="ip" type="text" value="195.187.6.34" inputmode="text" autocomplete="off" spellcheck="false" aria-describedby="ip-error" required>
<p class="field-error" id="ip-error" role="alert" hidden></p>
</div>
<div class="form-controls">
<div class="form-status"><i class="signal" aria-hidden="true"></i><span id="validation-status">Waiting for a source URL</span></div>
<button type="button" class="quiet" data-action="clear">Reset fields</button>
</div>
</form>
</article>
<main class="container">
<section class="card hero">
<div class="hero-text">
<h1>Convert adblock lists to MikroTik / hosts</h1>
<p class="muted">Paste a list URL (AdGuard/uBlock/hosts/dnsmasq), pick the target IP, and generate a
<kbd>/convert</kbd> link.
</p>
</div>
<div class="hero-cta">
<a class="btn primary large" href="#form">Start</a>
</div>
</section>
<aside class="side-column">
<section class="card guide-card" id="guide">
<div class="card-heading"><div><span class="small-label">02 / WORKFLOW</span><h2>How it works</h2></div></div>
<div class="workflow-step"><span class="step-num">01</span><div><strong>Choose a source</strong><p>Add a public URL to a supported list.</p></div></div>
<div class="workflow-step"><span class="step-num">02</span><div><strong>Set your target</strong><p>Select the IP used for blocked domains.</p></div></div>
<div class="workflow-step"><span class="step-num">03</span><div><strong>Copy and deploy</strong><p>Use the generated address in IP → DNS → Adlist.</p></div></div>
</section>
<section class="card support-card">
<header class="card-heading"><div><span class="small-label">INPUT / COMPATIBILITY</span><h2>Supported formats</h2></div><span class="pill">TEXT</span></header>
<div class="format-list"><span>Hosts</span><span>AdGuard / uBlock</span><span>dnsmasq</span><span>Plain domains</span></div>
<p class="fine">The source is checked before the response is delivered. Invalid lists return an English error page.</p>
</section>
</aside>
</section>
<section id="form" class="card form-card" aria-labelledby="form-title">
<h2 id="form-title">Link generator</h2>
<section class="card result-card" id="result" aria-labelledby="result-heading">
<header class="card-heading">
<div><span class="small-label">03 / GENERATED OUTPUT</span><h2 id="result-heading">Your conversion endpoint</h2></div>
<span class="pill" id="link-state">AWAITING INPUT</span>
</header>
<p class="fine result-intro">The link updates as you type. Generating the address does not download the source; conversion runs when the endpoint is requested.</p>
<div class="output-row">
<div class="output-field"><span class="output-mark">GET</span><input id="generated-link" type="text" readonly aria-label="Generated conversion link" placeholder="Enter a valid source URL to generate your link"></div>
<div class="output-actions">
<button type="button" class="primary" data-action="copy" data-target="#generated-link" id="copy-btn" disabled>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="1"/><path d="M16 8V4H4v12h4"/></svg> Copy link
</button>
<a class="outline" id="open-link" href="#" target="_blank" rel="noopener noreferrer" aria-disabled="true" tabindex="-1">Open <span aria-hidden="true">↗</span></a>
</div>
</div>
<div class="result-footer"><span class="success-note"><i class="signal" aria-hidden="true"></i> VALID RESULTS ARE CACHEABLE</span><span>Shared cache: up to {{ cache_days }} days (proxy configuration required)</span></div>
</section>
<form method="GET" action="/" novalidate>
<div class="grid">
<div class="form-group col-12">
<label for="url-input">URL to hosts/adblock list</label>
<input id="url-input" type="url" name="url" required placeholder="https://example.com/list.txt"
inputmode="url" autocomplete="url" aria-describedby="url-help" autofocus>
<small id="url-help" class="hint"> Supported formats: <code>||domain^</code>,
<code>address=/domain/</code>, hosts files, plain domains.</small>
<div class="error" data-error-for="url-input"></div>
</div>
<div class="form-group col-6">
<label for="ip-input">Target IP</label>
<input id="ip-input" type="text" name="ip" value="195.187.6.34" required inputmode="text"
autocomplete="off" aria-describedby="ip-help" spellcheck="false">
<small id="ip-help" class="hint">Common choices: <code>0.0.0.0</code>, <code>127.0.0.1</code>,
your device IP, supports IPv4 and IPv6.</small>
<div class="error" data-error-for="ip-input"></div>
</div>
<div class="form-group col-6">
<label for="ip-preset">Presets</label>
<select id="ip-preset" class="select">
<option value="">— choose preset —</option>
<option value="0.0.0.0">0.0.0.0 (blackhole)</option>
<option value="127.0.0.1">127.0.0.1 (localhost)</option>
<option value="195.187.6.34">195.187.6.34 (current)</option>
<option value="custom">Custom…</option>
</select>
</div>
<div class="form-actions col-12">
<button class="btn ghost" type="button" data-action="clear">Clear</button>
</div>
</div>
</form>
<div class="result-box" data-state="empty" aria-live="polite" aria-atomic="true">
<div class="result-row">
<input id="generated-link" type="text" value="{{ generated_link or '' }}" readonly
placeholder="Link will appear here…">
<div class="result-buttons">
<button class="btn" type="button" id="copy-btn" data-action="copy"
data-target="#generated-link">Copy</button>
<a class="btn outline" id="open-link" href="{{ generated_link or '#' }}" target="_blank"
rel="noopener" aria-disabled="{{ 'false' if generated_link else 'true' }}">Open</a>
</div>
</div>
<small class="hint">Paste this link in your Mikrotik (IP -> DNS -> Adlist) or other DNS server /
ad blocking tool</small>
</div>
</section>
<section class="card recent-card">
<div class="section-head">
<h2>Recent converts</h2>
<div class="head-actions">
<button class="btn ghost" type="button" data-action="collapse" aria-expanded="false"
aria-controls="recent-list">Expand</button>
</div>
</div>
<div id="recent-list" class="recent-list" style="display:none">
{% if recent_links %}
{% for link_data in recent_links %}
<article class="link-item">
<div class="link-main">
<a class="mono ellipsis" title="{{ link_data[1] }}"
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
rel="noopener">
{{ link_data[1] }}
</a>
<span class="arrow">→</span>
<span class="mono ellipsis" title="{{ link_data[2] }}">{{ link_data[2] }}</span>
</div>
<div class="link-meta">
<span class="timestamp">{{ link_data[0]|datetimeformat }}</span>
<div class="link-actions">
<button class="btn tiny" type="button" data-action="copy-text"
data-text="{{ url_for('convert', _external=True) }}?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}">Copy</button>
<a class="btn tiny outline"
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
rel="noopener">Open</a>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p class="muted">Empty..</p>
{% endif %}
</div>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a> · All rights
reserved</div>
<div class="meta">Your IP: <strong>{{ client_ip }}</strong> · UA: <strong>{{ user_agent }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script>
// no-flash theme bootstrap
(function () { const t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); })();
</script>
</body>
</html>
<section class="card recent-card" id="recent-links" aria-labelledby="recent-heading">
<header class="card-heading card-heading-responsive">
<div><span class="small-label">04 / RECENT ACTIVITY</span><h2 id="recent-heading">Recently converted sources <span class="count">{{ recent_links|length }}</span></h2></div>
<div class="head-actions"><label class="sr-only" for="recent-filter">Filter recent sources</label><input id="recent-filter" type="search" placeholder="Search sources or IPs…" data-filter="#recent-table" autocomplete="off"></div>
</header>
<div class="table-scroll">
<table class="data-table" id="recent-table">
<thead><tr><th>Source</th><th>Target IP</th><th>Last used</th><th class="actions-cell">Actions</th></tr></thead>
<tbody>
{% for item in recent_links %}
<tr {% if loop.index > 8 %}hidden data-extra-row="true"{% endif %}>
<td class="source-cell"><span class="source-glyph" aria-hidden="true">↗</span><a href="{{ url_for('convert',url=item[1],ip=item[2]) }}" target="_blank" rel="noopener noreferrer" title="{{ item[1] }}">{{ item[1] }}</a></td>
<td class="mono"><span class="ip-label">{{ item[2] }}</span></td>
<td class="mono subdued nowrap">{{ item[0]|datetimeformat }}</td>
<td class="row-actions">
<button type="button" class="quiet tiny" data-action="use-source" data-url="{{ item[1] }}" data-ip="{{ item[2] }}">Use</button>
<button type="button" class="quiet tiny" data-action="copy-text" data-text="{{ url_for('convert', url=item[1], ip=item[2]) }}">Copy</button>
<a class="quiet tiny" href="{{ url_for('convert',url=item[1],ip=item[2]) }}" target="_blank" rel="noopener noreferrer">Open ↗</a>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="4" class="empty-table"><span class="empty-title">No recent links yet</span><span>Successful conversions will appear here.</span></td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if recent_links|length > 8 %}
<div class="table-footer"><span class="fine">Showing 8 of {{ recent_links|length }} entries</span><button class="quiet tiny" type="button" data-action="show-all" data-target="#recent-table">Show all entries ↓</button></div>
{% endif %}
<p class="fine no-results" id="recent-empty" hidden>No entries match this search.</p>
</section>
{% endblock %}
+81 -216
View File
@@ -1,216 +1,81 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stats</title>
<meta name="theme-color" content="#0f1115">
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<header class="site-header">
<div class="brand">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
</svg>
<span>Hosts Converter</span>
</div>
<nav class="actions">
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
<a class="btn ghost" href="/" rel="nofollow">Home</a>
</nav>
</header>
<main class="container container--wide">
<!-- KPIs -->
<section class="card kpi-card">
<h2 class="section-title">Overview</h2>
<div class="kpi-grid">
<div class="kpi">
<div class="kpi-label">Convert requests</div>
<div class="kpi-value">{{ stats.get('stats:convert_requests', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Successful conversions</div>
<div class="kpi-value">{{ stats.get('stats:conversions_success', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Errors 4xx</div>
<div class="kpi-value">{{ stats.get('stats:errors_400', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Errors 5xx</div>
<div class="kpi-value">{{ stats.get('stats:errors_500', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Avg processing (s)</div>
<div class="kpi-value">{{ '%.3f' % detailed.processing_time_avg_sec }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Avg content (bytes)</div>
<div class="kpi-value">{{ detailed.content_size_avg_bytes }}</div>
</div>
</div>
</section>
<!-- Recent converts -->
<section class="card">
<div class="section-head">
<h2>Recent converts (latest {{ recent|length }})</h2>
<div class="head-actions">
<input class="table-filter" type="search" placeholder="Filter…" data-action="filter-table"
data-target="#recent-table">
</div>
</div>
<div class="table-wrap">
<table id="recent-table" class="data-table">
<thead>
<tr>
<th>Time</th>
<th>URL</th>
<th>Target IP</th>
<th>Client</th>
<th>User agent</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in recent %}
{% set q = row.get('url','') %}
{% set parts = q.split('&ip=') %}
{% set url = parts[0].replace('/convert?url=', '') | urlencode %}
{% set ip = (parts[1] if parts|length > 1 else '') %}
<tr>
<td class="mono nowrap">{{ row.time|datetimeformat }}</td>
<td class="mono ellipsis" title="{{ url|safe }}">
{{ url|safe }}
</td>
<td class="mono">{{ ip }}</td>
<td class="mono ellipsis" title="{{ row.hostname }} ({{ row.ip }})">
{{ row.hostname }} ({{ row.ip }})
</td>
<td class="ellipsis" title="{{ row.user_agent }}">{{ row.user_agent }}</td>
<td class="actions">
<a class="btn tiny outline" href="{{ q }}" target="_blank" rel="noopener">Open</a>
<button class="btn tiny" data-action="copy-text" data-text="{{ q }}">Copy</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<!-- Top tables -->
<section class="card">
<div class="section-head">
<h2>Top sources</h2>
</div>
<div class="grid">
<div class="col-6">
<h3 class="subhead">Source URLs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>URL</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for u, c in url_requests.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono ellipsis" title="{{ u }}">{{ u }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">Target IPs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>IP</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ip, c in target_ips.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono">{{ ip }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">User agents</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>User agent</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ua, c in user_agents.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="ellipsis" title="{{ ua }}">{{ ua }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">Client IPs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Client IP</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ip, c in client_ips.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono">{{ ip }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a></div>
<div class="meta">Your IP: <strong>{{ request.remote_addr }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/stats.js') }}"></script>
<script>(function () { const t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); })();</script>
</body>
</html>
{% extends 'base.html' %}
{% set page = 'stats' %}
{% block title %}Statistics{% endblock %}
{% block content %}
<section class="page-intro stats-intro">
<div><div class="eyebrow"><i class="signal" aria-hidden="true"></i> REDIS COUNTERS / OBSERVABILITY</div>
<h1>Every request, <em>in focus.</em></h1>
<p class="lead">Conversion traffic, output sizes, client activity and recent requests. Metrics reflect traffic reaching this application; proxy cache hits do not execute the converter.</p></div>
<div class="intro-action"><a class="outline" href="{{ url_for('stats_json') }}" target="_blank" rel="noopener noreferrer">Open JSON API ↗</a></div>
</section>
{% set error4xx = (stats.get('stats:errors_400',0)|int + stats.get('stats:errors_403',0)|int + stats.get('stats:errors_404',0)|int + stats.get('stats:errors_413',0)|int + stats.get('stats:errors_415',0)|int + stats.get('stats:errors_422',0)|int + stats.get('stats:errors_429',0)|int) %}
{% set error5xx = (stats.get('stats:errors_500',0)|int + stats.get('stats:errors_502',0)|int) %}
<section class="stats-grid" aria-label="Overview metrics">
<article class="stat"><span>Convert requests</span><strong>{{ stats.get('stats:convert_requests',0) }}</strong><small>Reached backend</small></article>
<article class="stat"><span>Successful conversions</span><strong>{{ stats.get('stats:conversions_success',0) }}</strong><small>Validated responses</small></article>
<article class="stat"><span>4xx / 5xx errors</span><strong>{{ error4xx + error5xx }}</strong><small>{{ error4xx }} client / {{ error5xx }} server</small></article>
<article class="stat"><span>Avg. processing</span><strong>{{ '%.3f'|format(detailed.processing_time_avg_sec) }}<small class="unit">s</small></strong><small>Requests served by backend</small></article>
</section>
<section class="stats-secondary" aria-label="Performance metrics">
<div><span>AVERAGE OUTPUT</span><strong>{{ '{:,.0f}'.format(detailed.content_size_avg_bytes) }} B</strong></div>
<div><span>TOTAL OUTPUT</span><strong>{{ '{:,.0f}'.format(detailed.content_size_total_bytes) }} B</strong></div>
<div><span>FASTEST REQUEST</span><strong>{{ '%.3f'|format(detailed.processing_time_min_sec) }} s</strong></div>
<div><span>SLOWEST REQUEST</span><strong>{{ '%.3f'|format(detailed.processing_time_max_sec) }} s</strong></div>
</section>
<section class="card" id="activity">
<header class="card-heading card-heading-responsive"><div><span class="small-label">01 / TRAFFIC</span><h2>Recent conversion requests <span class="count">{{ recent|length }}</span></h2></div>
<div class="head-actions"><label class="sr-only" for="stats-filter">Filter requests</label><input id="stats-filter" type="search" data-filter="#activity-table" placeholder="Search traffic…"></div></header>
<div class="table-scroll"><table class="data-table" id="activity-table">
<thead><tr><th>Time</th><th>Source</th><th>Target</th><th>Client</th><th>User agent</th><th>Open</th></tr></thead>
<tbody>
{% for row in recent %}
<tr>
<td class="mono subdued nowrap">{{ row.get('time','')|datetimeformat }}</td>
<td class="source-cell"><span class="source-glyph" aria-hidden="true">↗</span><span class="source-ellipsis" title="{{ row.get('source_url','') }}">{{ row.get('source_url','') or 'Unknown' }}</span></td>
<td class="mono"><span class="ip-label">{{ row.get('target_ip','') }}</span></td>
<td class="mono" title="{{ row.get('hostname','') }} ({{ row.get('ip','') }})">{{ row.get('ip','') }}</td>
<td title="{{ row.get('user_agent','') }}"><span class="source-ellipsis">{{ row.get('user_agent','') }}</span></td>
<td>{% if row.get('source_url') %}<a class="quiet tiny" href="{{ url_for('convert',url=row.get('source_url'),ip=row.get('target_ip')) }}" target="_blank" rel="noopener noreferrer">Open ↗</a>{% else %}—{% endif %}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6" class="empty-table"><span class="empty-title">No requests recorded</span><span>Recent conversion requests will appear here.</span></td></tr>
{% endfor %}
</tbody>
</table></div>
<p class="fine no-results" data-empty-for="#activity-table" hidden>No matching records.</p>
</section>
<section class="table-card-grid" id="top-sources">
<article class="card"><header class="card-heading"><div><span class="small-label">02 / LEADERBOARD</span><h2>Top sources</h2></div><span class="pill">URL</span></header>
<div class="table-scroll"><table class="data-table"><thead><tr><th>Source address</th><th class="right">Requests</th></tr></thead><tbody>
{% for url, total in url_requests.items()|sort(attribute=1,reverse=True) %}
<tr><td class="source-cell"><span class="source-ellipsis" title="{{ url }}">{{ url }}</span></td><td class="right mono">{{ total }}</td></tr>
{% else %}<tr><td colspan="2" class="empty-table">No sources recorded.</td></tr>{% endfor %}
</tbody></table></div>
</article>
<article class="card"><header class="card-heading"><div><span class="small-label">03 / LEADERBOARD</span><h2>Target addresses</h2></div><span class="pill">IP</span></header>
<div class="table-scroll"><table class="data-table"><thead><tr><th>Target IP</th><th class="right">Requests</th></tr></thead><tbody>
{% for ip, total in target_ips.items()|sort(attribute=1,reverse=True) %}
<tr><td class="mono"><span class="ip-label">{{ ip }}</span></td><td class="right mono">{{ total }}</td></tr>
{% else %}<tr><td colspan="2" class="empty-table">No targets recorded.</td></tr>{% endfor %}
</tbody></table></div>
</article>
<article class="card"><header class="card-heading"><div><span class="small-label">04 / CLIENTS</span><h2>Top client IPs</h2></div></header>
<div class="table-scroll"><table class="data-table"><thead><tr><th>Client IP</th><th class="right">Requests</th></tr></thead><tbody>
{% for ip, total in client_ips.items()|sort(attribute=1,reverse=True) %}
<tr><td class="mono"><span class="source-ellipsis">{{ ip }}</span></td><td class="right mono">{{ total }}</td></tr>
{% else %}<tr><td colspan="2" class="empty-table">No clients recorded.</td></tr>{% endfor %}
</tbody></table></div>
</article>
<article class="card"><header class="card-heading"><div><span class="small-label">05 / CLIENTS</span><h2>User agents</h2></div></header>
<div class="table-scroll"><table class="data-table"><thead><tr><th>User agent</th><th class="right">Requests</th></tr></thead><tbody>
{% for ua, total in user_agents.items()|sort(attribute=1,reverse=True) %}
<tr><td><span class="source-ellipsis" title="{{ ua }}">{{ ua }}</span></td><td class="right mono">{{ total }}</td></tr>
{% else %}<tr><td colspan="2" class="empty-table">No user agents recorded.</td></tr>{% endfor %}
</tbody></table></div>
</article>
</section>
<details class="card json-details"><summary><span><span class="small-label">06 / INSPECTION</span><strong>Raw JSON snapshot</strong></span><span class="fine">Expand <span aria-hidden="true">↓</span></span></summary>
<div class="json-actions"><p class="fine">This is application-side activity only. Requests served from RustProxy or Varnish cache are not counted here.</p><button type="button" class="quiet tiny" data-action="copy" data-target="#raw-json">Copy JSON</button></div>
<pre class="raw-json" id="raw-json">{{ raw_json }}</pre>
</details>
{% endblock %}