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
|
||||
zeigt nur die Kennzahlen und Diagramme seines Datentyps, die Auswahl wird im
|
||||
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)
|
||||
- `priorityweb/` – Vorschlagsverfeinerer (Port 8082, `jonassearch-priority.service`)
|
||||
- `search/jonassearch-engine.service` – systemd-Unit für die Suchmaschine
|
||||
|
||||
+199
-7
@@ -200,6 +200,44 @@ HTML = r"""
|
||||
.metric-panel { display: none; }
|
||||
.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; }
|
||||
|
||||
.range-btn {
|
||||
@@ -310,6 +348,8 @@ HTML = r"""
|
||||
@media (max-width: 600px) {
|
||||
.metric-btn { padding: 10px 12px; font-size: 0.9rem; gap: 9px; }
|
||||
.metric-icon { font-size: 1.3rem; }
|
||||
.goal-eta { margin-left: 0; font-size: 1.15rem; }
|
||||
.goal-input { width: 8rem; }
|
||||
.current { font-size: 2.4rem; }
|
||||
.container { padding: 20px 14px 40px; }
|
||||
.chart-container { padding: 14px; }
|
||||
@@ -355,6 +395,15 @@ HTML = r"""
|
||||
</button>
|
||||
</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">
|
||||
<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>
|
||||
@@ -892,6 +941,13 @@ function setMetric(next, persist) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1473,6 +1529,147 @@ function renderSummary(containerId, all, points) {
|
||||
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 ---------- */
|
||||
|
||||
function renderAll() {
|
||||
@@ -1635,13 +1832,7 @@ function renderAll() {
|
||||
document.getElementById('stat-min-hint').textContent = 'On ' + fmtDateFull(low.x);
|
||||
|
||||
/* forecast */
|
||||
const regWindow = [];
|
||||
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);
|
||||
const reg = recentTrend(fpoints);
|
||||
if (reg) {
|
||||
const elF = document.getElementById('stat-forecast');
|
||||
const fc = fpoints[fpoints.length - 1].y + reg.slope;
|
||||
@@ -1748,6 +1939,7 @@ function renderAll() {
|
||||
renderHeatmap(pendingAll, 'heatmap-pending', 'Pending');
|
||||
renderSummary('summary-images', imageAll, imageFpoints);
|
||||
renderSummary('summary-queue', pendingAll, pendingFpoints);
|
||||
renderGoal();
|
||||
}
|
||||
|
||||
async function update() {
|
||||
|
||||
@@ -289,6 +289,40 @@ class StatsPageTest(unittest.TestCase):
|
||||
self.assertIn("return { name: 'jonassearch_images'", 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):
|
||||
client = stats_app.app.test_client()
|
||||
response = client.get("/")
|
||||
|
||||
Reference in New Issue
Block a user