add stuff
This commit is contained in:
@@ -18,6 +18,10 @@ das komplette serverjonas search
|
|||||||
Die Hauptseite hat drei Tabs mit Symbolen (Pages, Images, Queue): Jeder Tab
|
Die Hauptseite hat drei Tabs mit Symbolen (Pages, Images, Queue): Jeder Tab
|
||||||
zeigt nur die Kennzahlen und Diagramme seines Datentyps, die Auswahl wird im
|
zeigt nur die Kennzahlen und Diagramme seines Datentyps, die Auswahl wird im
|
||||||
Browser gespeichert. CSV/JSON-Export folgt dem aktiven Tab.
|
Browser gespeichert. CSV/JSON-Export folgt dem aktiven Tab.
|
||||||
|
Über das Feld „Reach target“ lässt sich eine beliebige Zielzahl eingeben;
|
||||||
|
die Seite schätzt aus dem Trend der letzten zwei Stunden (des gewählten
|
||||||
|
Zeitraums), wann dieser Stand erreicht ist – inklusive Datum und Uhrzeit.
|
||||||
|
Das Ziel wird pro Tab im Browser gespeichert.
|
||||||
- `web/tui.py` – Terminal-Dashboard für dieselben Statistiken (siehe unten)
|
- `web/tui.py` – Terminal-Dashboard für dieselben Statistiken (siehe unten)
|
||||||
- `priorityweb/` – Vorschlagsverfeinerer (Port 8082, `jonassearch-priority.service`)
|
- `priorityweb/` – Vorschlagsverfeinerer (Port 8082, `jonassearch-priority.service`)
|
||||||
- `search/jonassearch-engine.service` – systemd-Unit für die Suchmaschine
|
- `search/jonassearch-engine.service` – systemd-Unit für die Suchmaschine
|
||||||
|
|||||||
+199
-7
@@ -200,6 +200,44 @@ HTML = r"""
|
|||||||
.metric-panel { display: none; }
|
.metric-panel { display: none; }
|
||||||
.metric-panel.active { display: block; }
|
.metric-panel.active { display: block; }
|
||||||
|
|
||||||
|
.goal {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-label { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.goal-input {
|
||||||
|
width: 10rem;
|
||||||
|
background: rgba(128,128,128,0.10);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.goal-unit { color: var(--muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.goal-eta {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-hint { flex-basis: 100%; color: var(--muted); font-size: 0.8rem; }
|
||||||
|
|
||||||
.section-title-first { margin-top: 6px; }
|
.section-title-first { margin-top: 6px; }
|
||||||
|
|
||||||
.range-btn {
|
.range-btn {
|
||||||
@@ -310,6 +348,8 @@ HTML = r"""
|
|||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.metric-btn { padding: 10px 12px; font-size: 0.9rem; gap: 9px; }
|
.metric-btn { padding: 10px 12px; font-size: 0.9rem; gap: 9px; }
|
||||||
.metric-icon { font-size: 1.3rem; }
|
.metric-icon { font-size: 1.3rem; }
|
||||||
|
.goal-eta { margin-left: 0; font-size: 1.15rem; }
|
||||||
|
.goal-input { width: 8rem; }
|
||||||
.current { font-size: 2.4rem; }
|
.current { font-size: 2.4rem; }
|
||||||
.container { padding: 20px 14px 40px; }
|
.container { padding: 20px 14px 40px; }
|
||||||
.chart-container { padding: 14px; }
|
.chart-container { padding: 14px; }
|
||||||
@@ -355,6 +395,15 @@ HTML = r"""
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="goal">
|
||||||
|
<label class="goal-label" for="goal-input">Reach target</label>
|
||||||
|
<input class="goal-input" id="goal-input" type="number" inputmode="numeric"
|
||||||
|
min="0" step="1" placeholder="e.g. 2000000" autocomplete="off">
|
||||||
|
<span class="goal-unit" id="goal-unit">Pages</span>
|
||||||
|
<div class="goal-eta" id="goal-eta">–</div>
|
||||||
|
<div class="goal-hint" id="goal-hint">Enter a number – how long until the selected tab reaches it at the current speed.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
<span class="badge"><span class="dot" id="status-dot"></span><span id="status-text">Loading…</span></span>
|
<span class="badge"><span class="dot" id="status-dot"></span><span id="status-text">Loading…</span></span>
|
||||||
<span class="badge" id="badge-interval">Interval: –</span>
|
<span class="badge" id="badge-interval">Interval: –</span>
|
||||||
@@ -892,6 +941,13 @@ function setMetric(next, persist) {
|
|||||||
try { localStorage.setItem('js-metric', activeMetric); } catch (e) {}
|
try { localStorage.setItem('js-metric', activeMetric); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* the target field belongs to the active tab, so recall its own value */
|
||||||
|
const goalInput = document.getElementById('goal-input');
|
||||||
|
if (goalInput) {
|
||||||
|
goalInput.value = goalTargets[activeMetric] || '';
|
||||||
|
renderGoal();
|
||||||
|
}
|
||||||
|
|
||||||
if (rawValues.length) renderAll();
|
if (rawValues.length) renderAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,6 +1529,147 @@ function renderSummary(containerId, all, points) {
|
|||||||
el.innerHTML = cards.join('');
|
el.innerHTML = cards.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- target ETA: "how long until we reach N?" ---------- */
|
||||||
|
|
||||||
|
// Linear trend over the last two hours of the given series (at most 96
|
||||||
|
// points). Falls back to the whole series when that window is too short.
|
||||||
|
function recentTrend(points) {
|
||||||
|
if (!points || points.length < 2) return null;
|
||||||
|
|
||||||
|
const anchor = points[points.length - 1].x;
|
||||||
|
const tail = [];
|
||||||
|
for (let i = points.length - 1; i >= 0; i--) {
|
||||||
|
tail.unshift(points[i]);
|
||||||
|
if (points.length - i >= 96) break;
|
||||||
|
if (anchor - points[i].x >= 2 * 3600000) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return regress(tail.length >= 2 ? tail : points);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longer horizons than fmtDur handles need months/years instead of "4123 d".
|
||||||
|
function fmtEta(ms) {
|
||||||
|
const min = ms / 60000;
|
||||||
|
if (min < 1) return '<1 min';
|
||||||
|
if (min < 60) return Math.round(min) + ' min';
|
||||||
|
|
||||||
|
const hours = min / 60;
|
||||||
|
if (hours < 24) return Math.floor(hours) + ' h ' + Math.round(min % 60) + ' min';
|
||||||
|
|
||||||
|
const days = hours / 24;
|
||||||
|
if (days < 90) return Math.floor(days) + ' d ' + Math.floor(hours % 24) + ' h';
|
||||||
|
|
||||||
|
const months = days / 30.44;
|
||||||
|
if (months < 24) return '≈ ' + Math.round(months) + ' months';
|
||||||
|
return '≈ ' + (days / 365.25).toFixed(1) + ' years';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember one target per tab; the field always belongs to the active tab.
|
||||||
|
const goalTargets = (function () {
|
||||||
|
const saved = { pages: '', images: '', queue: '' };
|
||||||
|
try {
|
||||||
|
Object.keys(saved).forEach(function (metric) {
|
||||||
|
saved[metric] = localStorage.getItem('js-goal-' + metric) || '';
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
return saved;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Series the active tab is showing (images honour the estimate toggle).
|
||||||
|
function goalSeries() {
|
||||||
|
const all = parsePoints(rawValues);
|
||||||
|
if (activeMetric === 'images') {
|
||||||
|
const points = toImagePoints(all, showEstimatedImages);
|
||||||
|
return { all: points, points: filterRange(points) };
|
||||||
|
}
|
||||||
|
if (activeMetric === 'queue') {
|
||||||
|
const points = toPendingPoints(all);
|
||||||
|
return { all: points, points: filterRange(points) };
|
||||||
|
}
|
||||||
|
return { all: all, points: filterRange(all) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGoal() {
|
||||||
|
const input = document.getElementById('goal-input');
|
||||||
|
const eta = document.getElementById('goal-eta');
|
||||||
|
const hint = document.getElementById('goal-hint');
|
||||||
|
const unit = document.getElementById('goal-unit');
|
||||||
|
if (!input || !eta || !hint || !unit) return;
|
||||||
|
|
||||||
|
const label = METRIC_LABELS[activeMetric];
|
||||||
|
unit.textContent = label;
|
||||||
|
eta.textContent = '–';
|
||||||
|
|
||||||
|
const raw = String(input.value || '').trim();
|
||||||
|
if (raw === '') {
|
||||||
|
hint.textContent = 'Enter a number – how long until ' + label +
|
||||||
|
' reach it at the current speed.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = Number(raw);
|
||||||
|
if (!isFinite(target) || target < 0) {
|
||||||
|
hint.textContent = 'Please enter a number of 0 or more.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = goalSeries();
|
||||||
|
const latest = series.all[series.all.length - 1];
|
||||||
|
if (!latest) {
|
||||||
|
hint.textContent = 'No ' + label + ' measurements yet – cannot estimate.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = series.all;
|
||||||
|
const points = series.points.length >= 2 ? series.points : all;
|
||||||
|
const trend = recentTrend(points);
|
||||||
|
const speed = trend ? trend.slope : null; // per hour
|
||||||
|
const current = latest.y;
|
||||||
|
const remaining = target - current;
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
eta.textContent = 'Now';
|
||||||
|
hint.textContent = 'Exactly at ' + fmt(current) + ' ' + label.toLowerCase() + '.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speed === null || speed === 0) {
|
||||||
|
hint.textContent = 'No measurable change at the current speed – cannot estimate.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining * speed < 0) {
|
||||||
|
hint.textContent = 'Moving away from the target (' + fmtSigned(speed) + ' ' + label +
|
||||||
|
'/h in the selected range) – it will not be reached at this speed.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const etaMs = remaining / speed * 3600000;
|
||||||
|
eta.textContent = fmtEta(etaMs);
|
||||||
|
eta.classList.remove('pos', 'neg');
|
||||||
|
eta.classList.add(speed > 0 ? 'pos' : 'neg');
|
||||||
|
|
||||||
|
hint.textContent = (remaining > 0 ? '+' : '−') + fmt(Math.abs(remaining)) +
|
||||||
|
' to ' + fmt(target) + ' at ' + fmtSigned(speed) + ' ' + label + '/h → around ' +
|
||||||
|
fmtDateFull(latest.x + etaMs) + ' · now ' + fmt(current) + ' ' + label + '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const input = document.getElementById('goal-input');
|
||||||
|
if (!input) return;
|
||||||
|
input.value = goalTargets[activeMetric] || '';
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
const raw = input.value.trim();
|
||||||
|
const value = Number(raw);
|
||||||
|
// Only remember valid targets, so a typo does not replace the saved one.
|
||||||
|
if (raw === '' || (isFinite(value) && value >= 0)) {
|
||||||
|
goalTargets[activeMetric] = raw;
|
||||||
|
try { localStorage.setItem('js-goal-' + activeMetric, raw); } catch (e) {}
|
||||||
|
}
|
||||||
|
renderGoal();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
/* ---------- main update ---------- */
|
/* ---------- main update ---------- */
|
||||||
|
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
@@ -1635,13 +1832,7 @@ function renderAll() {
|
|||||||
document.getElementById('stat-min-hint').textContent = 'On ' + fmtDateFull(low.x);
|
document.getElementById('stat-min-hint').textContent = 'On ' + fmtDateFull(low.x);
|
||||||
|
|
||||||
/* forecast */
|
/* forecast */
|
||||||
const regWindow = [];
|
const reg = recentTrend(fpoints);
|
||||||
for (let i = fpoints.length - 1; i >= 0; i--) {
|
|
||||||
regWindow.unshift(fpoints[i]);
|
|
||||||
if (fpoints.length - i >= 96) break;
|
|
||||||
if (fpoints[fpoints.length - 1].x - fpoints[i].x >= 2 * 3600000) break;
|
|
||||||
}
|
|
||||||
const reg = regress(regWindow);
|
|
||||||
if (reg) {
|
if (reg) {
|
||||||
const elF = document.getElementById('stat-forecast');
|
const elF = document.getElementById('stat-forecast');
|
||||||
const fc = fpoints[fpoints.length - 1].y + reg.slope;
|
const fc = fpoints[fpoints.length - 1].y + reg.slope;
|
||||||
@@ -1748,6 +1939,7 @@ function renderAll() {
|
|||||||
renderHeatmap(pendingAll, 'heatmap-pending', 'Pending');
|
renderHeatmap(pendingAll, 'heatmap-pending', 'Pending');
|
||||||
renderSummary('summary-images', imageAll, imageFpoints);
|
renderSummary('summary-images', imageAll, imageFpoints);
|
||||||
renderSummary('summary-queue', pendingAll, pendingFpoints);
|
renderSummary('summary-queue', pendingAll, pendingFpoints);
|
||||||
|
renderGoal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function update() {
|
async function update() {
|
||||||
|
|||||||
@@ -289,6 +289,40 @@ class StatsPageTest(unittest.TestCase):
|
|||||||
self.assertIn("return { name: 'jonassearch_images'", html)
|
self.assertIn("return { name: 'jonassearch_images'", html)
|
||||||
self.assertIn("return { name: 'jonassearch_queue'", html)
|
self.assertIn("return { name: 'jonassearch_queue'", html)
|
||||||
|
|
||||||
|
def test_page_has_target_eta_field_for_active_tab(self):
|
||||||
|
client = stats_app.app.test_client()
|
||||||
|
response = client.get("/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html = response.get_data(as_text=True)
|
||||||
|
|
||||||
|
# a plain number field at the top, plus the computed duration
|
||||||
|
self.assertIn('id="goal-input"', html)
|
||||||
|
self.assertIn('type="number" inputmode="numeric"', html)
|
||||||
|
self.assertIn('id="goal-eta"', html)
|
||||||
|
self.assertIn('id="goal-hint"', html)
|
||||||
|
self.assertIn('id="goal-unit"', html)
|
||||||
|
self.assertIn("Reach target", html)
|
||||||
|
|
||||||
|
# the estimate uses the trend of the selected range (last 2 h first)
|
||||||
|
self.assertIn("function recentTrend(points)", html)
|
||||||
|
self.assertIn("const reg = recentTrend(fpoints);", html)
|
||||||
|
self.assertIn("function fmtEta(ms)", html)
|
||||||
|
self.assertIn("const speed = trend ? trend.slope : null;", html)
|
||||||
|
self.assertIn("const etaMs = remaining / speed * 3600000;", html)
|
||||||
|
self.assertIn("if (remaining === 0) {", html)
|
||||||
|
self.assertIn("Moving away from the target", html)
|
||||||
|
|
||||||
|
# one target per tab, restored on tab switch and persisted per browser
|
||||||
|
self.assertIn("function goalSeries()", html)
|
||||||
|
self.assertIn("goalTargets[activeMetric] || ''", html)
|
||||||
|
self.assertIn("localStorage.setItem('js-goal-' + activeMetric, raw)", html)
|
||||||
|
self.assertIn("localStorage.getItem('js-goal-' + metric)", html)
|
||||||
|
self.assertIn("toImagePoints(all, showEstimatedImages)", html)
|
||||||
|
|
||||||
|
# recomputed on refresh, tab switch and range switch
|
||||||
|
self.assertIn("renderSummary('summary-queue', pendingAll, pendingFpoints);\n renderGoal();", html)
|
||||||
|
self.assertIn("goalInput.value = goalTargets[activeMetric] || '';\n renderGoal();", html)
|
||||||
|
|
||||||
def test_page_contains_pending_queue_counter_and_graphs(self):
|
def test_page_contains_pending_queue_counter_and_graphs(self):
|
||||||
client = stats_app.app.test_client()
|
client = stats_app.app.test_client()
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
|
|||||||
Reference in New Issue
Block a user