Various fixes. Split template files. Add systemd service.
This commit is contained in:
581
app.py
581
app.py
@@ -1,16 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from mirror_manager import (
|
from __future__ import annotations
|
||||||
load_mirrors,
|
|
||||||
add_mirror,
|
import os
|
||||||
update_mirror,
|
|
||||||
MIRROR_ROOT,
|
|
||||||
LOG_ROOT,
|
|
||||||
)
|
|
||||||
import re
|
|
||||||
import html
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask,
|
Flask,
|
||||||
request,
|
request,
|
||||||
@@ -18,356 +13,39 @@ from flask import (
|
|||||||
url_for,
|
url_for,
|
||||||
jsonify,
|
jsonify,
|
||||||
send_from_directory,
|
send_from_directory,
|
||||||
render_template_string
|
render_template,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
BASE = Path("/srv/www")
|
BASE = Path("/srv/www")
|
||||||
STATIC_DIR = BASE / "static"
|
STATIC_DIR = BASE / "static"
|
||||||
STATIC_DIR.mkdir(exist_ok=True)
|
STATIC_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Where the Mirage API lives
|
||||||
|
MIRAGE_API_BASE = os.environ.get(
|
||||||
|
"MIRAGE_API_BASE", "http://127.0.0.1:5151/api/v1"
|
||||||
|
)
|
||||||
|
# How mirrors are exposed over HTTP (for links)
|
||||||
|
MIRROR_HTTP_BASE = os.environ.get("MIRROR_HTTP_BASE", "/mirrors/")
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _run_update_in_background(slug: str):
|
def _api_url(path: str) -> str:
|
||||||
th = threading.Thread(target=update_mirror, args=(slug,), daemon=True)
|
return urljoin(MIRAGE_API_BASE.rstrip("/") + "/", path.lstrip("/"))
|
||||||
th.start()
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------- TEMPLATES --------------------
|
def api_get(path: str, **kwargs):
|
||||||
INDEX_TEMPLATE = r"""
|
resp = requests.get(_api_url(path), timeout=5, **kwargs)
|
||||||
<!doctype html>
|
resp.raise_for_status()
|
||||||
<html class="h-full">
|
return resp.json()
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Mirror Manager</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static_file', filename='tailwind.css') }}">
|
|
||||||
</head>
|
|
||||||
<body class="h-full bg-slate-950 text-slate-100">
|
|
||||||
<div class="min-h-full">
|
|
||||||
<header class="border-b border-slate-800 bg-slate-950/80 backdrop-blur">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-xl font-semibold tracking-tight">Mirror Manager</h1>
|
|
||||||
<p class="text-xs text-slate-400">Local offline mirrors of external sites, grouped by category.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 text-xs text-slate-400">
|
|
||||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-slate-700 bg-slate-900/70">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
|
||||||
Running locally
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="max-w-5xl mx-auto px-4 py-4 space-y-4">
|
|
||||||
<!-- Mirrors list -->
|
|
||||||
<section class="bg-slate-950/80 border border-slate-800 rounded-2xl p-4 shadow-xl shadow-black/40">
|
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span class="text-xs text-slate-400">Categories:</span>
|
|
||||||
<button class="px-2.5 py-1 rounded-full text-xs border bg-slate-900 border-slate-700 text-slate-100 hover:border-sky-500 cat-pill cat-pill-active" data-category="all">
|
|
||||||
All ({{ mirrors|length }})
|
|
||||||
</button>
|
|
||||||
{% for cat in categories %}
|
|
||||||
<button class="px-2.5 py-1 rounded-full text-xs border bg-slate-900 border-slate-800 text-slate-400 hover:border-sky-500 hover:text-slate-100 cat-pill" data-category="{{ cat }}">
|
|
||||||
{{ cat }}
|
|
||||||
</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input
|
|
||||||
id="search"
|
|
||||||
class="w-full md:w-64 rounded-full bg-slate-900 border border-slate-700 px-3 py-1.5 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
|
||||||
placeholder="Filter by slug / URL / category…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto border border-slate-800 rounded-xl">
|
|
||||||
<table class="min-w-full text-sm">
|
|
||||||
<thead class="bg-slate-900/70 text-xs uppercase text-slate-400">
|
|
||||||
<tr>
|
|
||||||
<th class="px-3 py-2 text-left whitespace-nowrap">Slug</th>
|
|
||||||
<th class="px-3 py-2 text-left whitespace-nowrap">Categories</th>
|
|
||||||
<th class="px-3 py-2 text-left whitespace-nowrap">URL</th>
|
|
||||||
<th class="px-3 py-2 text-left whitespace-nowrap">Last updated</th>
|
|
||||||
<th class="px-3 py-2 text-left whitespace-nowrap">Status</th>
|
|
||||||
<th class="px-3 py-2 text-left"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="mirror-table" class="divide-y divide-slate-900/80">
|
|
||||||
{% for m in mirrors %}
|
|
||||||
<tr class="hover:bg-slate-900/80 transition" data-slug="{{ m.slug }}" data-categories="{{ m.categories_joined }}" data-search="{{ (m.slug ~ ' ' ~ m.categories_joined ~ ' ' ~ m.url)|lower }}">
|
|
||||||
<td class="px-3 py-2 align-top">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<a href="/mirrors/{{ m.slug }}/" target="_blank" class="font-mono text-xs text-sky-400 hover:text-sky-300 break-all">
|
|
||||||
{{ m.slug }}
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('log_view', slug=m.slug) }}" target="_blank" class="text-[0.65rem] text-slate-400 hover:text-slate-200">
|
|
||||||
View live log
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2 align-top">
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{% for c in m.categories %}
|
|
||||||
<span class="px-1.5 py-0.5 rounded-full text-[0.65rem] bg-slate-800/80 text-slate-300 border border-slate-700">{{ c }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2 align-top max-w-xs">
|
|
||||||
<code class="font-mono text-[0.7rem] text-slate-300 break-all">{{ m.url }}</code>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2 align-top text-xs text-slate-300">
|
|
||||||
{% if m.last_updated %}
|
|
||||||
<span title="{{ m.last_updated_raw }}">{{ m.last_updated }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-slate-600">never</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2 align-top text-xs">
|
|
||||||
{% set st = m.status or 'idle' %}
|
|
||||||
<div class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-slate-900 border border-slate-800">
|
|
||||||
<span class="w-2 h-2 rounded-full
|
|
||||||
{% if st == 'idle' %}bg-emerald-400{% elif st == 'updating' %}bg-amber-400 animate-pulse{% elif st == 'warning' %}bg-yellow-400{% else %}bg-rose-400{% endif %}"></span>
|
|
||||||
<span class="capitalize">{{ st }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2 align-top text-right text-[0.7rem]">
|
|
||||||
<form method="post" action="{{ url_for('trigger_update', slug=m.slug) }}" class="inline">
|
|
||||||
<button class="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-slate-700 text-slate-200 hover:border-sky-500 hover:text-sky-100">
|
|
||||||
<span>Update</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% if mirrors|length == 0 %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-3 py-6 text-center text-sm text-slate-500">
|
|
||||||
No mirrors yet. Add one below.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Add mirror -->
|
|
||||||
<section class="bg-slate-950/80 border border-slate-800 rounded-2xl p-4 shadow-xl shadow-black/40 space-y-3">
|
|
||||||
<h2 class="text-sm font-semibold">Add mirror</h2>
|
|
||||||
<form method="post" action="{{ url_for('add_mirror_route') }}" class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label for="slug" class="block text-xs font-medium text-slate-300 mb-1">Slug</label>
|
|
||||||
<input id="slug" name="slug" required class="w-full rounded-lg bg-slate-900 border border-slate-700 px-2.5 py-1.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 font-mono" placeholder="e.g. wgpu-tutorial" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="categories" class="block text-xs font-medium text-slate-300 mb-1">Categories</label>
|
|
||||||
<input id="categories" name="categories" required class="w-full rounded-lg bg-slate-900 border border-slate-700 px-2.5 py-1.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500" placeholder="e.g. tutorials, graphics, rust" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="url" class="block text-xs font-medium text-slate-300 mb-1">URL</label>
|
|
||||||
<input id="url" name="url" required class="w-full rounded-lg bg-slate-900 border border-slate-700 px-2.5 py-1.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500" placeholder="https://example.com/some/path/" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<input id="ignore_robots" name="ignore_robots" value="1" type="checkbox" class="mt-0.5 rounded border-slate-600 bg-slate-900 text-sky-500 focus:ring-sky-500" />
|
|
||||||
<label for="ignore_robots" class="text-xs text-slate-400">
|
|
||||||
Ignore robots.txt (only if you explicitly want to archive disallowed paths).
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% if error %}
|
|
||||||
<p class="text-xs text-rose-300 bg-rose-950/60 border border-rose-900 rounded-lg px-2 py-1">{{ error }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="w-full inline-flex items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-2 text-xs font-medium text-white hover:from-sky-400 hover:to-indigo-400">
|
|
||||||
Add & mirror
|
|
||||||
</button>
|
|
||||||
<p class="text-[0.7rem] text-slate-500">
|
|
||||||
New mirrors are cloned in the background. Status will show as <span class="text-amber-300">updating</span> until done.
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Content search -->
|
|
||||||
<section class="bg-slate-950/80 border border-slate-800 rounded-2xl p-4 shadow-xl shadow-black/40">
|
|
||||||
<h2 class="text-sm font-semibold mb-2">Content search</h2>
|
|
||||||
<form id="search-form" class="space-y-2">
|
|
||||||
<input id="content-query" class="w-full rounded-lg bg-slate-900 border border-slate-700 px-2.5 py-1.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500" placeholder="Search text across all mirrors (using rg)…" />
|
|
||||||
<button type="submit" class="w-full inline-flex items-center justify-center gap-1.5 rounded-full border border-slate-700 bg-slate-900 px-3 py-2 text-xs font-medium text-slate-100 hover:border-sky-500 hover:text-sky-100">
|
|
||||||
Run ripgrep search
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div id="search-results" class="mt-2 max-h-64 overflow-y-auto text-[0.7rem] space-y-1 text-slate-300"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Category + name filter
|
|
||||||
const pills = Array.from(document.querySelectorAll('.cat-pill'));
|
|
||||||
const rows = Array.from(document.querySelectorAll('#mirror-table tr[data-slug]'));
|
|
||||||
const searchInput = document.getElementById('search');
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
const active = pills.find(p => p.classList.contains('cat-pill-active'));
|
|
||||||
const cat = active ? active.dataset.category : 'all';
|
|
||||||
const q = (searchInput.value || '').toLowerCase();
|
|
||||||
|
|
||||||
rows.forEach(row => {
|
|
||||||
const cats = row.dataset.categories.split(',').map(s => s.trim());
|
|
||||||
const searchStr = row.dataset.search;
|
|
||||||
const matchesCat = (cat === 'all' || cats.includes(cat));
|
|
||||||
const matchesSearch = (!q || searchStr.includes(q));
|
|
||||||
row.style.display = (matchesCat && matchesSearch) ? '' : 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pills.forEach(p => {
|
|
||||||
p.addEventListener('click', () => {
|
|
||||||
pills.forEach(x => x.classList.remove('cat-pill-active', 'border-sky-500', 'text-slate-100'));
|
|
||||||
p.classList.add('cat-pill-active', 'border-sky-500', 'text-slate-100');
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
searchInput.addEventListener('input', applyFilters);
|
|
||||||
|
|
||||||
// Live status polling
|
|
||||||
async function pollStatus() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("{{ url_for('status') }}");
|
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
const bySlug = {};
|
|
||||||
data.mirrors.forEach(m => bySlug[m.slug] = m);
|
|
||||||
|
|
||||||
rows.forEach(row => {
|
|
||||||
const slug = row.dataset.slug;
|
|
||||||
const m = bySlug[slug];
|
|
||||||
if (!m) return;
|
|
||||||
const tds = row.querySelectorAll('td');
|
|
||||||
const lastCell = tds[3];
|
|
||||||
const statusCell = tds[4];
|
|
||||||
|
|
||||||
lastCell.innerHTML = m.last_updated_display || '<span class="text-slate-600">never</span>';
|
|
||||||
|
|
||||||
const st = m.status || 'idle';
|
|
||||||
statusCell.innerHTML =
|
|
||||||
'<div class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-slate-900 border border-slate-800">' +
|
|
||||||
'<span class="w-2 h-2 rounded-full ' +
|
|
||||||
(st === "idle" ? "bg-emerald-400" :
|
|
||||||
st === "updating" ? "bg-amber-400 animate-pulse" :
|
|
||||||
st === "warning" ? "bg-yellow-400" : "bg-rose-400") +
|
|
||||||
'"></span>' +
|
|
||||||
'<span class="capitalize">' + st + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
});
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
setInterval(pollStatus, 5000);
|
|
||||||
|
|
||||||
// Content search via rg
|
|
||||||
const searchForm = document.getElementById('search-form');
|
|
||||||
const contentQuery = document.getElementById('content-query');
|
|
||||||
const searchResults = document.getElementById('search-results');
|
|
||||||
|
|
||||||
searchForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const q = contentQuery.value.trim();
|
|
||||||
if (!q) return;
|
|
||||||
searchResults.textContent = 'Searching…';
|
|
||||||
try {
|
|
||||||
const resp = await fetch("{{ url_for('content_search') }}?q=" + encodeURIComponent(q));
|
|
||||||
if (!resp.ok) {
|
|
||||||
searchResults.textContent = 'Search failed.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.results.length === 0) {
|
|
||||||
searchResults.textContent = 'No matches.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
searchResults.innerHTML = '';
|
|
||||||
|
|
||||||
data.results.forEach(r => {
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = "border border-slate-800 rounded-lg px-2 py-1 bg-slate-900/70";
|
|
||||||
|
|
||||||
const pathLine = document.createElement('div');
|
|
||||||
pathLine.className = "font-mono text-[0.65rem] text-sky-300 break-all";
|
|
||||||
|
|
||||||
if (r.url) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = r.url;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
link.textContent = r.path + (r.line ? `:${r.line}` : "");
|
|
||||||
pathLine.appendChild(link);
|
|
||||||
} else {
|
|
||||||
pathLine.textContent = r.path + (r.line ? `:${r.line}` : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const snippetLine = document.createElement('div');
|
|
||||||
snippetLine.className = "text-[0.7rem] text-slate-200 whitespace-pre-wrap";
|
|
||||||
snippetLine.textContent = r.snippet || "";
|
|
||||||
|
|
||||||
wrapper.appendChild(pathLine);
|
|
||||||
wrapper.appendChild(snippetLine);
|
|
||||||
searchResults.appendChild(wrapper);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
searchResults.textContent = 'Search failed.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
LOG_TEMPLATE = r"""
|
def api_post(path: str, json=None, **kwargs):
|
||||||
<!doctype html>
|
resp = requests.post(_api_url(path), json=json, timeout=10, **kwargs)
|
||||||
<html class="h-full">
|
return resp
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Log: {{ slug }}</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static_file', filename='tailwind.css') }}">
|
|
||||||
</head>
|
|
||||||
<body class="h-full bg-slate-950 text-slate-100">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 py-4 space-y-2">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-sm font-semibold">Log for <span class="font-mono text-sky-400">{{ slug }}</span></h1>
|
|
||||||
<p class="text-[0.65rem] text-slate-400">Live tail of wget output (auto-refreshing).</p>
|
|
||||||
</div>
|
|
||||||
<a href="/mirrors/{{ slug }}/" target="_blank" class="text-xs text-sky-400 hover:text-sky-200">Open mirror</a>
|
|
||||||
</div>
|
|
||||||
<div class="border border-slate-800 rounded-xl bg-slate-950/90 max-h-[75vh] overflow-y-auto">
|
|
||||||
<pre id="log" class="text-[0.65rem] p-3 font-mono whitespace-pre-wrap"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
const logEl = document.getElementById('log');
|
|
||||||
async function pollLog() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("{{ url_for('log_tail', slug=slug) }}");
|
|
||||||
if (!resp.ok) return;
|
|
||||||
const text = await resp.text();
|
|
||||||
logEl.textContent = text;
|
|
||||||
logEl.parentElement.scrollTop = logEl.parentElement.scrollHeight;
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
setInterval(pollLog, 1500);
|
|
||||||
pollLog();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
# -------------------- ROUTES --------------------
|
# -------------------- ROUTES --------------------
|
||||||
|
|
||||||
|
|
||||||
@app.route("/static/<path:filename>")
|
@app.route("/static/<path:filename>")
|
||||||
def static_file(filename):
|
def static_file(filename):
|
||||||
return send_from_directory(STATIC_DIR, filename)
|
return send_from_directory(STATIC_DIR, filename)
|
||||||
@@ -375,7 +53,10 @@ def static_file(filename):
|
|||||||
|
|
||||||
@app.route("/", methods=["GET"])
|
@app.route("/", methods=["GET"])
|
||||||
def index():
|
def index():
|
||||||
mirrors = load_mirrors()
|
# Ask Mirage for mirrors
|
||||||
|
data = api_get("mirrors")
|
||||||
|
mirrors = data.get("mirrors", [])
|
||||||
|
|
||||||
cats = set()
|
cats = set()
|
||||||
rows = []
|
rows = []
|
||||||
for m in mirrors:
|
for m in mirrors:
|
||||||
@@ -393,25 +74,34 @@ def index():
|
|||||||
"last_updated_raw": raw,
|
"last_updated_raw": raw,
|
||||||
"last_updated": disp,
|
"last_updated": disp,
|
||||||
})
|
})
|
||||||
return render_template_string(INDEX_TEMPLATE, mirrors=rows, categories=sorted(cats), error=None)
|
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
mirrors=rows,
|
||||||
|
categories=sorted(cats),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/add", methods=["POST"])
|
@app.route("/add", methods=["POST"])
|
||||||
def add_mirror_route():
|
def add_mirror_route():
|
||||||
slug = (request.form.get("slug") or "").strip()
|
slug = (request.form.get("slug") or "").strip()
|
||||||
categories = (request.form.get("categories") or "").strip()
|
categories_raw = (request.form.get("categories") or "").strip()
|
||||||
url = (request.form.get("url") or "").strip()
|
url = (request.form.get("url") or "").strip()
|
||||||
ignore_robots = bool(request.form.get("ignore_robots"))
|
ignore_robots = bool(request.form.get("ignore_robots"))
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if not slug or not categories or not url:
|
if not slug or not categories_raw or not url:
|
||||||
error = "Slug, categories, and URL are required."
|
error = "Slug, categories, and URL are required."
|
||||||
elif " " in slug:
|
elif " " in slug:
|
||||||
error = "Slug cannot contain spaces."
|
error = "Slug cannot contain spaces."
|
||||||
|
|
||||||
|
categories = [c.strip() for c in categories_raw.split(",") if c.strip()]
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
# re-render with error
|
# Same re-render pattern as before, but using Mirage API now
|
||||||
mirrors = load_mirrors()
|
data = api_get("mirrors")
|
||||||
|
mirrors = data.get("mirrors", [])
|
||||||
cats = set()
|
cats = set()
|
||||||
rows = []
|
rows = []
|
||||||
for m in mirrors:
|
for m in mirrors:
|
||||||
@@ -429,12 +119,31 @@ def add_mirror_route():
|
|||||||
"last_updated_raw": raw,
|
"last_updated_raw": raw,
|
||||||
"last_updated": disp,
|
"last_updated": disp,
|
||||||
})
|
})
|
||||||
return render_template_string(INDEX_TEMPLATE, mirrors=rows, categories=sorted(cats), error=error), 400
|
return (
|
||||||
|
render_template("index.html",
|
||||||
|
mirrors=rows,
|
||||||
|
categories=sorted(cats),
|
||||||
|
error=error),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"slug": slug,
|
||||||
|
"url": url,
|
||||||
|
"categories": categories,
|
||||||
|
"ignore_robots": ignore_robots,
|
||||||
|
"enqueue": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = api_post("mirrors", json=payload)
|
||||||
|
if resp.status_code >= 400:
|
||||||
try:
|
try:
|
||||||
add_mirror(slug, categories, url, ignore_robots=ignore_robots)
|
msg = resp.json().get("error", resp.text)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
mirrors = load_mirrors()
|
msg = resp.text or "Failed to create mirror."
|
||||||
|
# Same error re-render as above
|
||||||
|
data = api_get("mirrors")
|
||||||
|
mirrors = data.get("mirrors", [])
|
||||||
cats = set()
|
cats = set()
|
||||||
rows = []
|
rows = []
|
||||||
for m in mirrors:
|
for m in mirrors:
|
||||||
@@ -452,21 +161,27 @@ def add_mirror_route():
|
|||||||
"last_updated_raw": raw,
|
"last_updated_raw": raw,
|
||||||
"last_updated": disp,
|
"last_updated": disp,
|
||||||
})
|
})
|
||||||
return render_template_string(INDEX_TEMPLATE, mirrors=rows, categories=sorted(cats), error=str(e)), 400
|
return (
|
||||||
|
render_template("index.html",
|
||||||
|
mirrors=rows,
|
||||||
|
categories=sorted(cats),
|
||||||
|
error=msg),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
_run_update_in_background(slug)
|
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/update/<slug>", methods=["POST"])
|
@app.route("/update/<slug>", methods=["POST"])
|
||||||
def trigger_update(slug):
|
def trigger_update(slug):
|
||||||
_run_update_in_background(slug)
|
api_post(f"mirrors/{slug}/update")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/status", methods=["GET"])
|
@app.route("/status", methods=["GET"])
|
||||||
def status():
|
def status():
|
||||||
mirrors = load_mirrors()
|
data = api_get("mirrors")
|
||||||
|
mirrors = data.get("mirrors", [])
|
||||||
out = []
|
out = []
|
||||||
for m in mirrors:
|
for m in mirrors:
|
||||||
raw = m.get("last_updated")
|
raw = m.get("last_updated")
|
||||||
@@ -484,78 +199,23 @@ def status():
|
|||||||
|
|
||||||
@app.route("/logs/<slug>")
|
@app.route("/logs/<slug>")
|
||||||
def log_view(slug):
|
def log_view(slug):
|
||||||
log_path = LOG_ROOT / f"{slug}.log"
|
# Template just polls /logs/<slug>/tail via JS
|
||||||
if not log_path.exists():
|
return render_template("log.html", slug=slug)
|
||||||
log_path.touch()
|
|
||||||
return render_template_string(LOG_TEMPLATE, slug=slug)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/logs/<slug>/tail")
|
@app.route("/logs/<slug>/tail")
|
||||||
def log_tail(slug):
|
def log_tail(slug):
|
||||||
log_path = LOG_ROOT / f"{slug}.log"
|
|
||||||
if not log_path.exists():
|
|
||||||
return "", 200
|
|
||||||
try:
|
try:
|
||||||
with log_path.open("rb") as f:
|
resp = requests.get(_api_url(f"mirrors/{slug}/log"),
|
||||||
f.seek(0, 2)
|
params={"bytes": 65536},
|
||||||
size = f.tell()
|
timeout=5)
|
||||||
block = 65536
|
except requests.RequestException:
|
||||||
if size <= block:
|
|
||||||
f.seek(0)
|
|
||||||
data = f.read()
|
|
||||||
else:
|
|
||||||
f.seek(-block, 2)
|
|
||||||
data = f.read()
|
|
||||||
return data.decode("utf-8", errors="replace")
|
|
||||||
except OSError:
|
|
||||||
return "", 200
|
return "", 200
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return "", 200
|
||||||
|
|
||||||
def strip_html(text: str) -> str:
|
return resp.text, 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||||
# Remove script and style blocks first
|
|
||||||
text = re.sub(
|
|
||||||
r"<script\b[^<]*(?:(?!</script>)<[^<]*)*</script>",
|
|
||||||
" ",
|
|
||||||
text,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
text = re.sub(
|
|
||||||
r"<style\b[^<]*(?:(?!</style>)<[^<]*)*</style>",
|
|
||||||
" ",
|
|
||||||
text,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# Strip all remaining tags
|
|
||||||
text = re.sub(r"<[^>]+>", " ", text)
|
|
||||||
# Unescape HTML entities (& → &, etc.)
|
|
||||||
text = html.unescape(text)
|
|
||||||
# Collapse whitespace
|
|
||||||
text = re.sub(r"\s+", " ", text).strip()
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def make_snippet(text: str,
|
|
||||||
query: str,
|
|
||||||
radius: int = 80,
|
|
||||||
max_len: int = 240) -> str:
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
lower = text.lower()
|
|
||||||
qlower = query.lower()
|
|
||||||
idx = lower.find(qlower)
|
|
||||||
if idx == -1:
|
|
||||||
snippet = text[:max_len]
|
|
||||||
if len(text) > max_len:
|
|
||||||
snippet += "…"
|
|
||||||
return snippet
|
|
||||||
start = max(0, idx - radius)
|
|
||||||
end = min(len(text), idx + len(query) + radius)
|
|
||||||
snippet = text[start:end]
|
|
||||||
if start > 0:
|
|
||||||
snippet = "…" + snippet
|
|
||||||
if end < len(text):
|
|
||||||
snippet += "…"
|
|
||||||
return snippet
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/search", methods=["GET"])
|
@app.route("/search", methods=["GET"])
|
||||||
@@ -565,73 +225,46 @@ def content_search():
|
|||||||
return jsonify({"results": []})
|
return jsonify({"results": []})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
resp = requests.get(_api_url("search"),
|
||||||
[
|
params={"q": q},
|
||||||
"rg",
|
timeout=10)
|
||||||
"--line-number",
|
except requests.RequestException:
|
||||||
"--no-heading",
|
|
||||||
"--color", "never",
|
|
||||||
"--max-count", "5", # per file
|
|
||||||
"--type-add", "page:*.{html,htm,md,markdown,txt}",
|
|
||||||
"-tpage",
|
|
||||||
q,
|
|
||||||
str(MIRROR_ROOT),
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"results": [{
|
"results": [{
|
||||||
"path": "(error)",
|
"path": "(error)",
|
||||||
"line": 0,
|
"line": 0,
|
||||||
"url": "",
|
"url": "",
|
||||||
"snippet": "ripgrep (rg) is not installed."
|
"snippet": "Search request to Mirage API failed.",
|
||||||
}]
|
|
||||||
})
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return jsonify({
|
|
||||||
"results": [{
|
|
||||||
"path": "(error)",
|
|
||||||
"line": 0,
|
|
||||||
"url": "",
|
|
||||||
"snippet": "rg timed out."
|
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return jsonify({
|
||||||
|
"results": [{
|
||||||
|
"path": "(error)",
|
||||||
|
"line": 0,
|
||||||
|
"url": "",
|
||||||
|
"snippet": f"Mirage API returned {resp.status_code}.",
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
results = []
|
results = []
|
||||||
for line in proc.stdout.splitlines():
|
for r in data.get("results", []):
|
||||||
parts = line.split(":", 2)
|
rel_path = r.get("path", "")
|
||||||
if len(parts) != 3:
|
lineno = int(r.get("line", 0))
|
||||||
continue
|
snippet = r.get("snippet") or ""
|
||||||
path, lineno, raw_content = parts
|
|
||||||
|
|
||||||
# Strip HTML/JS/CSS markup from this line before making a snippet
|
# Web UI decides how mirrors are exposed over HTTP:
|
||||||
text_content = strip_html(raw_content)
|
url = MIRROR_HTTP_BASE.rstrip("/") + "/" + rel_path.replace("\\", "/")
|
||||||
if not text_content:
|
|
||||||
continue
|
|
||||||
|
|
||||||
snippet = make_snippet(text_content, q)
|
|
||||||
|
|
||||||
try:
|
|
||||||
rel_path = str(Path(path).relative_to(MIRROR_ROOT))
|
|
||||||
except ValueError:
|
|
||||||
rel_path = path
|
|
||||||
|
|
||||||
url = "/mirrors/" + rel_path.replace("\\", "/")
|
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"path": rel_path,
|
"path": rel_path,
|
||||||
"line": int(lineno),
|
"line": lineno,
|
||||||
"url": url,
|
"url": url,
|
||||||
"snippet": snippet,
|
"snippet": snippet,
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(results) >= 50:
|
|
||||||
break
|
|
||||||
|
|
||||||
return jsonify({"results": results})
|
return jsonify({"results": results})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Mirror Manager Flask App
|
Description=Mirage-Web Interface
|
||||||
|
Requires=mirage-api.service
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=aargonian
|
User=mirage
|
||||||
Group=aargonian
|
Group=mirage
|
||||||
WorkingDirectory=/srv/www
|
WorkingDirectory=/srv/www
|
||||||
Environment="FLASK_ENV=production"
|
Environment="FLASK_ENV=production"
|
||||||
ExecStart=/usr/bin/python3 /srv/www/app.py
|
ExecStart=/usr/bin/python3 /srv/www/mirage/mirage-web/app.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
287
templates/index.html
Executable file
287
templates/index.html
Executable file
@@ -0,0 +1,287 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Mirror Manager</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static_file', filename='tailwind.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-slate-950 text-slate-100">
|
||||||
|
<div class="min-h-full">
|
||||||
|
<header class="border-b border-slate-800 bg-slate-950/80 backdrop-blur">
|
||||||
|
<div class="max-w-5xl mx-auto px-4 py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold tracking-tight">Mirror Manager</h1>
|
||||||
|
<p class="text-xs text-slate-400">Local offline mirrors of external sites, grouped by category.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-slate-700 bg-slate-900/70">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||||
|
Running locally
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-4 py-4 space-y-4">
|
||||||
|
<!-- Mirrors list -->
|
||||||
|
<section class="bg-slate-950/80 border border-slate-800 rounded-2xl p-4 shadow-xl shadow-black/40">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-xs text-slate-400">Categories:</span>
|
||||||
|
<button class="px-2.5 py-1 rounded-full text-xs border bg-slate-900 border-slate-700 text-slate-100 hover:border-sky-500 cat-pill cat-pill-active" data-category="all">
|
||||||
|
All ({{ mirrors|length }})
|
||||||
|
</button>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<button class="px-2.5 py-1 rounded-full text-xs border bg-slate-900 border-slate-800 text-slate-400 hover:border-sky-500 hover:text-slate-100 cat-pill" data-category="{{ cat }}">
|
||||||
|
{{ cat }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
class="w-full md:w-64 rounded-full bg-slate-900 border border-slate-700 px-3 py-1.5 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
placeholder="Filter by slug / URL / category…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto border border-slate-800 rounded-xl">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-slate-900/70 text-xs uppercase text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left whitespace-nowrap">Slug</th>
|
||||||
|
<th class="px-3 py-2 text-left whitespace-nowrap">Categories</th>
|
||||||
|
<th class="px-3 py-2 text-left whitespace-nowrap">URL</th>
|
||||||
|
<th class="px-3 py-2 text-left whitespace-nowrap">Last updated</th>
|
||||||
|
<th class="px-3 py-2 text-left whitespace-nowrap">Status</th>
|
||||||
|
<th class="px-3 py-2 text-left"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mirror-table" class="divide-y divide-slate-900/80">
|
||||||
|
{% for m in mirrors %}
|
||||||
|
<tr class="hover:bg-slate-900/80 transition" data-slug="{{ m.slug }}" data-categories="{{ m.categories_joined }}" data-search="{{ (m.slug ~ ' ' ~ m.categories_joined ~ ' ' ~ m.url)|lower }}">
|
||||||
|
<td class="px-3 py-2 align-top">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<a href="/mirrors/{{ m.slug }}/" target="_blank" class="font-mono text-xs text-sky-400 hover:text-sky-300 break-all">
|
||||||
|
{{ m.slug }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('log_view', slug=m.slug) }}" target="_blank" class="text-[0.65rem] text-slate-400 hover:text-slate-200">
|
||||||
|
View live log
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 align-top">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for c in m.categories %}
|
||||||
|
<span class="px-1.5 py-0.5 rounded-full text-[0.65rem] bg-slate-800/80 text-slate-300 border border-slate-700">{{ c }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 align-top max-w-xs">
|
||||||
|
<code class="font-mono text-[0.7rem] text-slate-300 break-all">{{ m.url }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 align-top text-xs text-slate-300">
|
||||||
|
{% if m.last_updated %}
|
||||||
|
<span title="{{ m.last_updated_raw }}">{{ m.last_updated }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate-600">never</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 align-top text-xs">
|
||||||
|
{% set st = m.status or 'idle' %}
|
||||||
|
<div class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-slate-900 border border-slate-800">
|
||||||
|
<span class="w-2 h-2 rounded-full
|
||||||
|
{% if st == 'idle' %}bg-emerald-400{% elif st == 'updating' %}bg-amber-400 animate-pulse{% elif st == 'warning' %}bg-yellow-400{% else %}bg-rose-400{% endif %}"></span>
|
||||||
|
<span class="capitalize">{{ st }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 align-top text-right text-[0.7rem]">
|
||||||
|
<form method="post" action="{{ url_for('trigger_update', slug=m.slug) }}" class="inline">
|
||||||
|
<button class="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-slate-700 text-slate-200 hover:border-sky-500 hover:text-sky-100">
|
||||||
|
<span>Update</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if mirrors|length == 0 %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-3 py-6 text-center text-sm text-slate-500">
|
||||||
|
No mirrors yet. Add one below.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Add mirror -->
|
||||||
|
<section class="bg-slate-950/80 border border-slate-800 rounded-2xl p-4 shadow-xl shadow-black/40 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold">Add mirror</h2>
|
||||||
|
<form method="post" action="{{ url_for('add_mirror_route') }}" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label for="slug" class="block text-xs font-medium text-slate-300 mb-1">Slug</label>
|
||||||
|
<input id="slug" name="slug" required class="w-full rounded-lg bg-slate-900 border border-slate-700 px-2.5 py-1.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 font-mono" placeholder="e.g. wgpu-tutorial" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="categories" class="block text-xs font-medium text-slate-300 mb-1">Categories</label>
|
||||||
|
<input id="categories" name="categories" required class="w-full rounded-lg bg-slate-900 border border-slate-700 px-2.5 py-1.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500" placeholder="e.g. tutorials, graphics, rust" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="url" class="block text-xs font-medium text-slate-300 mb-1">URL</label>
|
||||||
|
<input id="url" name="url" required class="w-full rounded-lg bg-slate-900 border border-slate-700 px-2.5 py-1.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500" placeholder="https://example.com/some/path/" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<input id="ignore_robots" name="ignore_robots" value="1" type="checkbox" class="mt-0.5 rounded border-slate-600 bg-slate-900 text-sky-500 focus:ring-sky-500" />
|
||||||
|
<label for="ignore_robots" class="text-xs text-slate-400">
|
||||||
|
Ignore robots.txt (only if you explicitly want to archive disallowed paths).
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if error %}
|
||||||
|
<p class="text-xs text-rose-300 bg-rose-950/60 border border-rose-900 rounded-lg px-2 py-1">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="w-full inline-flex items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-2 text-xs font-medium text-white hover:from-sky-400 hover:to-indigo-400">
|
||||||
|
Add & mirror
|
||||||
|
</button>
|
||||||
|
<p class="text-[0.7rem] text-slate-500">
|
||||||
|
New mirrors are cloned in the background. Status will show as <span class="text-amber-300">updating</span> until done.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Content search -->
|
||||||
|
<section class="bg-slate-950/80 border border-slate-800 rounded-2xl p-4 shadow-xl shadow-black/40">
|
||||||
|
<h2 class="text-sm font-semibold mb-2">Content search</h2>
|
||||||
|
<form id="search-form" class="space-y-2">
|
||||||
|
<input id="content-query" class="w-full rounded-lg bg-slate-900 border border-slate-700 px-2.5 py-1.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500" placeholder="Search text across all mirrors (using rg)…" />
|
||||||
|
<button type="submit" class="w-full inline-flex items-center justify-center gap-1.5 rounded-full border border-slate-700 bg-slate-900 px-3 py-2 text-xs font-medium text-slate-100 hover:border-sky-500 hover:text-sky-100">
|
||||||
|
Run ripgrep search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="search-results" class="mt-2 max-h-64 overflow-y-auto text-[0.7rem] space-y-1 text-slate-300"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Category + name filter
|
||||||
|
const pills = Array.from(document.querySelectorAll('.cat-pill'));
|
||||||
|
const rows = Array.from(document.querySelectorAll('#mirror-table tr[data-slug]'));
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const active = pills.find(p => p.classList.contains('cat-pill-active'));
|
||||||
|
const cat = active ? active.dataset.category : 'all';
|
||||||
|
const q = (searchInput.value || '').toLowerCase();
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const cats = row.dataset.categories.split(',').map(s => s.trim());
|
||||||
|
const searchStr = row.dataset.search;
|
||||||
|
const matchesCat = (cat === 'all' || cats.includes(cat));
|
||||||
|
const matchesSearch = (!q || searchStr.includes(q));
|
||||||
|
row.style.display = (matchesCat && matchesSearch) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pills.forEach(p => {
|
||||||
|
p.addEventListener('click', () => {
|
||||||
|
pills.forEach(x => x.classList.remove('cat-pill-active', 'border-sky-500', 'text-slate-100'));
|
||||||
|
p.classList.add('cat-pill-active', 'border-sky-500', 'text-slate-100');
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', applyFilters);
|
||||||
|
|
||||||
|
// Live status polling
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("{{ url_for('status') }}");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const bySlug = {};
|
||||||
|
data.mirrors.forEach(m => bySlug[m.slug] = m);
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const slug = row.dataset.slug;
|
||||||
|
const m = bySlug[slug];
|
||||||
|
if (!m) return;
|
||||||
|
const tds = row.querySelectorAll('td');
|
||||||
|
const lastCell = tds[3];
|
||||||
|
const statusCell = tds[4];
|
||||||
|
|
||||||
|
lastCell.innerHTML = m.last_updated_display || '<span class="text-slate-600">never</span>';
|
||||||
|
|
||||||
|
const st = m.status || 'idle';
|
||||||
|
statusCell.innerHTML =
|
||||||
|
'<div class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-slate-900 border border-slate-800">' +
|
||||||
|
'<span class="w-2 h-2 rounded-full ' +
|
||||||
|
(st === "idle" ? "bg-emerald-400" :
|
||||||
|
st === "updating" ? "bg-amber-400 animate-pulse" :
|
||||||
|
st === "warning" ? "bg-yellow-400" : "bg-rose-400") +
|
||||||
|
'"></span>' +
|
||||||
|
'<span class="capitalize">' + st + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
setInterval(pollStatus, 5000);
|
||||||
|
|
||||||
|
// Content search via rg
|
||||||
|
const searchForm = document.getElementById('search-form');
|
||||||
|
const contentQuery = document.getElementById('content-query');
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
|
||||||
|
searchForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = contentQuery.value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
searchResults.textContent = 'Searching…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch("{{ url_for('content_search') }}?q=" + encodeURIComponent(q));
|
||||||
|
if (!resp.ok) {
|
||||||
|
searchResults.textContent = 'Search failed.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.results.length === 0) {
|
||||||
|
searchResults.textContent = 'No matches.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
|
||||||
|
data.results.forEach(r => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = "border border-slate-800 rounded-lg px-2 py-1 bg-slate-900/70";
|
||||||
|
|
||||||
|
const pathLine = document.createElement('div');
|
||||||
|
pathLine.className = "font-mono text-[0.65rem] text-sky-300 break-all";
|
||||||
|
|
||||||
|
if (r.url) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = r.url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.textContent = r.path + (r.line ? `:${r.line}` : "");
|
||||||
|
pathLine.appendChild(link);
|
||||||
|
} else {
|
||||||
|
pathLine.textContent = r.path + (r.line ? `:${r.line}` : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippetLine = document.createElement('div');
|
||||||
|
snippetLine.className = "text-[0.7rem] text-slate-200 whitespace-pre-wrap";
|
||||||
|
snippetLine.textContent = r.snippet || "";
|
||||||
|
|
||||||
|
wrapper.appendChild(pathLine);
|
||||||
|
wrapper.appendChild(snippetLine);
|
||||||
|
searchResults.appendChild(wrapper);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
searchResults.textContent = 'Search failed.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
templates/log.html
Executable file
36
templates/log.html
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Log: {{ slug }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static_file', filename='tailwind.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-slate-950 text-slate-100">
|
||||||
|
<div class="max-w-5xl mx-auto px-4 py-4 space-y-2">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-sm font-semibold">Log for <span class="font-mono text-sky-400">{{ slug }}</span></h1>
|
||||||
|
<p class="text-[0.65rem] text-slate-400">Live tail of wget output (auto-refreshing).</p>
|
||||||
|
</div>
|
||||||
|
<a href="/mirrors/{{ slug }}/" target="_blank" class="text-xs text-sky-400 hover:text-sky-200">Open mirror</a>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-800 rounded-xl bg-slate-950/90 max-h-[75vh] overflow-y-auto">
|
||||||
|
<pre id="log" class="text-[0.65rem] p-3 font-mono whitespace-pre-wrap"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const logEl = document.getElementById('log');
|
||||||
|
async function pollLog() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("{{ url_for('log_tail', slug=slug) }}");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const text = await resp.text();
|
||||||
|
logEl.textContent = text;
|
||||||
|
logEl.parentElement.scrollTop = logEl.parentElement.scrollHeight;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
setInterval(pollLog, 1500);
|
||||||
|
pollLog();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user