search-rotation 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/abort.js +14 -0
- package/dist/config.js +109 -0
- package/dist/engines/base.js +72 -0
- package/dist/engines/duckduckgo.js +98 -0
- package/dist/engines/exa.js +113 -0
- package/dist/engines/firecrawl.js +78 -0
- package/dist/engines/googlecse.js +41 -0
- package/dist/engines/hosted-mcp.js +24 -0
- package/dist/engines/index.js +13 -0
- package/dist/engines/jina.js +28 -0
- package/dist/engines/parallel.js +74 -0
- package/dist/engines/tavily.js +74 -0
- package/dist/history.js +59 -0
- package/dist/index.js +182 -0
- package/dist/mcp/engine-status.js +58 -0
- package/dist/mcp/http.js +35 -0
- package/dist/mcp/server.js +105 -0
- package/dist/persistence.js +80 -0
- package/dist/quota.js +37 -0
- package/dist/router.js +183 -0
- package/dist/search-time.js +31 -0
- package/dist/status.js +141 -0
- package/dist/types.js +1 -0
- package/dist/usage.js +95 -0
- package/dist/version.js +5 -0
- package/dist/web/app.js +155 -0
- package/dist/web/security.js +116 -0
- package/docs/assets/brand/FONT-LICENSE.txt +93 -0
- package/docs/assets/brand/README.md +18 -0
- package/docs/assets/brand/icon-128.png +0 -0
- package/docs/assets/brand/icon-32.png +0 -0
- package/docs/assets/brand/icon-512.png +0 -0
- package/docs/assets/brand/icon-dark-128.png +0 -0
- package/docs/assets/brand/icon-dark-512.png +0 -0
- package/docs/assets/brand/icon-dark.svg +1 -0
- package/docs/assets/brand/icon-light-128.png +0 -0
- package/docs/assets/brand/icon-light-512.png +0 -0
- package/docs/assets/brand/icon-light.svg +1 -0
- package/docs/assets/brand/icon-mono.svg +1 -0
- package/docs/assets/brand/icon.svg +1 -0
- package/docs/assets/brand/preview.png +0 -0
- package/docs/assets/brand/preview.svg +1 -0
- package/docs/assets/brand/wordmark-dark.svg +1 -0
- package/docs/assets/brand/wordmark-light.svg +1 -0
- package/docs/assets/dashboard-dark.png +0 -0
- package/docs/assets/dashboard-light.png +0 -0
- package/docs/assets/dashboard.png +0 -0
- package/docs/assets/mcp-tools-dark.png +0 -0
- package/docs/assets/mcp-tools-light.png +0 -0
- package/docs/assets/mcp-tools.png +0 -0
- package/docs/assets/search-rotation.svg +52 -0
- package/docs/clients.md +93 -0
- package/docs/operations.md +66 -0
- package/docs/provider-access.md +38 -0
- package/docs/release-notes.md +52 -0
- package/package.json +65 -0
- package/static/app.js +1126 -0
- package/static/engine-logos/README.md +11 -0
- package/static/engine-logos/duckduckgo.png +0 -0
- package/static/engine-logos/exa.png +0 -0
- package/static/engine-logos/firecrawl.png +0 -0
- package/static/engine-logos/google-cse.png +0 -0
- package/static/engine-logos/jina.png +0 -0
- package/static/engine-logos/parallel.png +0 -0
- package/static/engine-logos/tavily.png +0 -0
- package/static/i18n.js +709 -0
- package/static/index.html +95 -0
- package/static/style.css +1258 -0
package/static/app.js
ADDED
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/* search-rotation Konsole — SPA mit Hash-Routing.
|
|
4
|
+
Views: Übersicht (#/), Engines (#/engines), Verlauf (#/history), MCP Tools (#/tools) */
|
|
5
|
+
|
|
6
|
+
const $ = (s) => document.querySelector(s);
|
|
7
|
+
|
|
8
|
+
const state = {
|
|
9
|
+
meta: null,
|
|
10
|
+
config: null,
|
|
11
|
+
status: [],
|
|
12
|
+
history: [],
|
|
13
|
+
route: "/",
|
|
14
|
+
openEngines: new Set(), // aufgeklappte Engine-Drawer
|
|
15
|
+
historyOpen: new Set(), // aufgeklappte Verlaufseinträge
|
|
16
|
+
filters: { kind: "all", engine: "", q: "" },
|
|
17
|
+
livePaused: false,
|
|
18
|
+
dragFrom: null,
|
|
19
|
+
unauthorized: false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/* ---------- i18n ---------- */
|
|
23
|
+
|
|
24
|
+
const I18N = window.I18N;
|
|
25
|
+
let lang = localStorage.getItem("sr_lang") || "en";
|
|
26
|
+
if (!I18N[lang]) lang = "en";
|
|
27
|
+
|
|
28
|
+
function t(key, vars) {
|
|
29
|
+
let s = (I18N[lang] && I18N[lang][key]) || I18N.de[key] || key;
|
|
30
|
+
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll("{" + k + "}", String(v));
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function applyI18n() {
|
|
35
|
+
document.documentElement.lang = lang;
|
|
36
|
+
document.querySelectorAll("[data-i18n]").forEach((el) => (el.textContent = t(el.dataset.i18n)));
|
|
37
|
+
document.querySelectorAll("[data-i18n-title]").forEach((el) => (el.title = t(el.dataset.i18nTitle)));
|
|
38
|
+
$("#lang-code").textContent = lang.toUpperCase();
|
|
39
|
+
$("#theme-btn").title = t(document.documentElement.dataset.theme === "dark" ? "theme.toLight" : "theme.toDark");
|
|
40
|
+
document.querySelectorAll("#lang-menu [data-lang]").forEach((b) => b.classList.toggle("active", b.dataset.lang === lang));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ---------- Theme ---------- */
|
|
44
|
+
|
|
45
|
+
function initTheme() {
|
|
46
|
+
const saved = localStorage.getItem("sr_theme");
|
|
47
|
+
const theme = saved || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
|
48
|
+
setTheme(theme, false);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setTheme(theme, persist = true) {
|
|
52
|
+
document.documentElement.dataset.theme = theme;
|
|
53
|
+
$("#theme-icon").innerHTML = '<use href="#i-' + (theme === "dark" ? "sun" : "moon") + '"/>';
|
|
54
|
+
$("#theme-btn").title = t(theme === "dark" ? "theme.toLight" : "theme.toDark");
|
|
55
|
+
document.querySelector('meta[name="theme-color"]').setAttribute("content", theme === "dark" ? "#0a0c12" : "#f4f5fa");
|
|
56
|
+
if (persist) localStorage.setItem("sr_theme", theme);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ---------- Helfer ---------- */
|
|
60
|
+
|
|
61
|
+
function esc(s) {
|
|
62
|
+
const d = document.createElement("div");
|
|
63
|
+
d.textContent = String(s ?? "");
|
|
64
|
+
return d.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rtf = { format: (value, unit) => new Intl.RelativeTimeFormat(lang, { numeric: "auto" }).format(value, unit) };
|
|
68
|
+
|
|
69
|
+
function relTime(ts) {
|
|
70
|
+
const s = Math.round((Date.now() - new Date(ts).getTime()) / 1000);
|
|
71
|
+
if (s < 45) return rtf.format(-s, "second");
|
|
72
|
+
const m = Math.round(s / 60);
|
|
73
|
+
if (m < 45) return rtf.format(-m, "minute");
|
|
74
|
+
const h = Math.round(m / 60);
|
|
75
|
+
if (h < 22) return rtf.format(-h, "hour");
|
|
76
|
+
return rtf.format(-Math.round(h / 24), "day");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fmtMs(ms) {
|
|
80
|
+
return ms >= 1000 ? (ms / 1000).toLocaleString(lang, { maximumFractionDigits: 1 }) + " s" : Math.round(ms) + " ms";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ENGINE_HUES = { tavily: 174, exa: 262, firecrawl: 24, "google-cse": 217, jina: 199, parallel: 145, duckduckgo: 14 };
|
|
84
|
+
|
|
85
|
+
function engineHue(id) {
|
|
86
|
+
if (ENGINE_HUES[id] !== undefined) return ENGINE_HUES[id];
|
|
87
|
+
let h = 0;
|
|
88
|
+
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 360;
|
|
89
|
+
return h;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function avatarStyle(id) {
|
|
93
|
+
const h = engineHue(id);
|
|
94
|
+
return "background:linear-gradient(135deg,hsl(" + h + " 62% 52%),hsl(" + ((h + 28) % 360) + " 68% 40%))";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Bundled provider favicon; the delegated error handler retains the letter fallback. */
|
|
98
|
+
function engineLogoHtml(e) {
|
|
99
|
+
const letter = esc((e.label || e.id)[0]);
|
|
100
|
+
if (!Object.hasOwn(ENGINE_HUES, e.id)) return '<span class="avatar" style="' + avatarStyle(e.id) + '\">' + letter + "</span>";
|
|
101
|
+
return '<span class="logo-wrap"><img class="logo" src="/engine-logos/' + encodeURIComponent(e.id) +
|
|
102
|
+
'.png" alt="" loading="lazy" data-fallback="' + letter + '" data-hue="' + engineHue(e.id) + '\"></span>';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pctClass(pct) {
|
|
106
|
+
return pct > 40 ? "ok" : pct > 10 ? "warn" : "crit";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function bar(pct) {
|
|
110
|
+
return '<span class="bar" role="img" aria-label="' + esc(t("quota.free", { pct })) + '"><span class="fill ' + pctClass(pct) + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></span></span>';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function usageSummary(st) {
|
|
114
|
+
const search = st.used?.search || 0, fetch = st.used?.fetch || 0, errors = st.used?.errors || 0;
|
|
115
|
+
return { total: search + fetch + errors, search, fetch, errors };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function usageHtml(st) {
|
|
119
|
+
const counts = usageSummary(st);
|
|
120
|
+
return '<span class="usage-summary" title="' + esc(t("usage.help")) + '"><strong>' +
|
|
121
|
+
esc(t(counts.total === 1 ? "usage.totalOne" : "usage.total", { n: counts.total })) + '</strong><span class="usage-detail">' +
|
|
122
|
+
esc(t("usage.detail", counts)) + '</span></span>';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function quotaDescription(st) {
|
|
126
|
+
const q = st.quota;
|
|
127
|
+
if (!q || !(q.limit > 0) || !Number.isFinite(q.used)) return t("quota.unknown");
|
|
128
|
+
const format = n => n.toLocaleString(lang, { maximumFractionDigits: 2 });
|
|
129
|
+
return t("quota.remaining", { remaining: format(Math.max(0, q.limit - q.used)), limit: format(q.limit), unit: t("quota.unit." + q.unit) }) +
|
|
130
|
+
" · " + t(q.source === "remote" ? "quota.providerBalance" : "quota.period." + q.period) +
|
|
131
|
+
(q.timeZone ? " · " + q.timeZone : "") + " · " + t("quota.source." + q.source) + (q.estimated ? " · " + t("quota.estimated") : "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function budgetHtml(st) {
|
|
135
|
+
const pct = remainingPctOf(st);
|
|
136
|
+
const help = quotaDescription(st) + ". " + t(st.quota?.source === "remote" ? "quota.remoteHelp" : "quota.localHelp");
|
|
137
|
+
return '<span class="budget-summary" title="' + esc(help) + '">' +
|
|
138
|
+
(pct === null ? '' : bar(pct)) + '<span class="quota-label">' + esc(quotaDescription(st)) + '</span></span>' +
|
|
139
|
+
(st.remoteError ? '<span class="quota-err" title="' + esc(st.remoteError) + '">' + esc(t("quota.fetchError")) + '</span>' : '');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function quotaHtml(st) {
|
|
143
|
+
return st?.id ? usageHtml(st) + budgetHtml(st) : '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ringGauge(pct) {
|
|
147
|
+
const r = 21, c = 2 * Math.PI * r;
|
|
148
|
+
const label = pct === null ? t("quota.unknown") : t("quota.free", { pct });
|
|
149
|
+
const off = c * (1 - (pct ?? 0) / 100);
|
|
150
|
+
return '<span class="gauge" role="img" title="' + esc(label) + '" aria-label="' + esc(label) + '"><svg aria-hidden="true" width="52" height="52" viewBox="0 0 52 52">' +
|
|
151
|
+
'<circle class="track" cx="26" cy="26" r="' + r + '" fill="none" stroke-width="5"/>' +
|
|
152
|
+
(pct === null ? '' : '<circle class="val ' + pctClass(pct) + '" cx="26" cy="26" r="' + r + '" fill="none" stroke-width="5" stroke-dasharray="' + c.toFixed(1) + '" stroke-dashoffset="' + off.toFixed(1) + '"/>') +
|
|
153
|
+
'</svg><span class="gauge-val' + (pct === null ? ' inf' : '') + '">' + (pct === null ? '?' : pct + '%') + '</span></span>';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ---------- Token & API ---------- */
|
|
157
|
+
|
|
158
|
+
async function api(path, opts = {}) {
|
|
159
|
+
const res = await fetch(path, {
|
|
160
|
+
method: opts.method || "GET",
|
|
161
|
+
headers: { "content-type": "application/json" },
|
|
162
|
+
credentials: "same-origin",
|
|
163
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
164
|
+
});
|
|
165
|
+
if (res.status === 401) {
|
|
166
|
+
state.unauthorized = true;
|
|
167
|
+
$("#view").innerHTML = '<div class="auth-error"><div><p style="font-size:34px;margin:0 0 10px">🔒</p>' + t("api.unauthorized") + '<p><a href="/login">Anmelden / Sign in</a></p></div></div>';
|
|
168
|
+
throw new Error("unauthorized");
|
|
169
|
+
}
|
|
170
|
+
const data = await res.json().catch(() => ({}));
|
|
171
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
172
|
+
return data;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function refreshStatus() {
|
|
176
|
+
const s = await api("/api/status");
|
|
177
|
+
state.status = s.engines || [];
|
|
178
|
+
if (state.meta) state.meta.month = s.month;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function loadHistory() {
|
|
182
|
+
const data = await api("/api/history?limit=200");
|
|
183
|
+
state.history = data.entries || [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function load() {
|
|
187
|
+
state.meta = await api("/api/meta");
|
|
188
|
+
state.config = await api("/api/config");
|
|
189
|
+
await refreshStatus();
|
|
190
|
+
document.body.dataset.booted = "1";
|
|
191
|
+
$("#version").textContent = "v" + state.meta.version;
|
|
192
|
+
$("#configpath").textContent = state.meta.configPath;
|
|
193
|
+
$("#configpath").title = state.meta.configPath;
|
|
194
|
+
render();
|
|
195
|
+
loadHistory().then(renderIfDataView).catch(() => {});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderIfDataView() {
|
|
199
|
+
if (state.route === "/history" || state.route === "/") render();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ---------- Toasts ---------- */
|
|
203
|
+
|
|
204
|
+
function toast(kind, msg) {
|
|
205
|
+
const wrap = $("#toasts");
|
|
206
|
+
const el = document.createElement("div");
|
|
207
|
+
el.className = "toast " + kind;
|
|
208
|
+
el.innerHTML =
|
|
209
|
+
'<svg width="16" height="16"><use href="#i-' +
|
|
210
|
+
(kind === "ok" ? "check" : kind === "error" ? "x" : "zap") +
|
|
211
|
+
'"/></svg><span>' + esc(msg) + "</span>";
|
|
212
|
+
wrap.appendChild(el);
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
el.classList.add("leaving");
|
|
215
|
+
setTimeout(() => el.remove(), 260);
|
|
216
|
+
}, 3200);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ---------- Status-Zugriff ---------- */
|
|
220
|
+
|
|
221
|
+
function statusOf(id) {
|
|
222
|
+
return state.status.find((e) => e.id === id) || {};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sortedEngines() {
|
|
226
|
+
return [...state.status].sort((a, b) => a.searchPosition - b.searchPosition);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function anyKeySet() {
|
|
230
|
+
return state.status.some((e) => e.hasKey);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ---------- Konfig-PUTs (serialisiert) ---------- */
|
|
234
|
+
|
|
235
|
+
let putChain = Promise.resolve();
|
|
236
|
+
|
|
237
|
+
function queuePut(bodyBuilder) {
|
|
238
|
+
putChain = putChain
|
|
239
|
+
.then(() => api("/api/config", { method: "PUT", body: bodyBuilder(state.config) }))
|
|
240
|
+
.then(async (r) => {
|
|
241
|
+
state.config = r.config;
|
|
242
|
+
try {
|
|
243
|
+
await refreshStatus();
|
|
244
|
+
} catch {}
|
|
245
|
+
render();
|
|
246
|
+
toast("ok", t("toast.saved"));
|
|
247
|
+
})
|
|
248
|
+
.catch((err) => {
|
|
249
|
+
if (err.message !== "unauthorized") toast("error", t("toast.error", { msg: err.message }));
|
|
250
|
+
});
|
|
251
|
+
return putChain;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ---------- Router ---------- */
|
|
255
|
+
|
|
256
|
+
function parseHash() {
|
|
257
|
+
const h = location.hash.replace(/^#/, "");
|
|
258
|
+
return ["/", "/engines", "/history", "/tools"].includes(h) ? h : "/";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function setRoute(route) {
|
|
262
|
+
state.route = route;
|
|
263
|
+
document.querySelectorAll(".nav-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.route === route));
|
|
264
|
+
positionNavInd();
|
|
265
|
+
render();
|
|
266
|
+
window.scrollTo({ top: 0 });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function positionNavInd() {
|
|
270
|
+
const active = $(".nav-tab.active");
|
|
271
|
+
const ind = $("#nav-ind");
|
|
272
|
+
if (!active || !ind) return;
|
|
273
|
+
ind.style.width = active.offsetWidth + "px";
|
|
274
|
+
ind.style.transform = "translateX(" + active.offsetLeft + "px)";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function render() {
|
|
278
|
+
if (state.unauthorized) return;
|
|
279
|
+
applyI18n();
|
|
280
|
+
positionNavInd();
|
|
281
|
+
if (!state.config) return renderSkeleton();
|
|
282
|
+
if (state.route === "/engines") renderEngines();
|
|
283
|
+
else if (state.route === "/history") renderHistory();
|
|
284
|
+
else if (state.route === "/tools") renderTools();
|
|
285
|
+
else renderOverview();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function renderSkeleton() {
|
|
289
|
+
$("#view").innerHTML =
|
|
290
|
+
'<div class="page">' +
|
|
291
|
+
'<div class="skeleton" style="height:52px;margin-bottom:16px"></div>' +
|
|
292
|
+
'<div class="overview-grid">' + '<div class="skeleton" style="height:110px"></div>'.repeat(4) + "</div>" +
|
|
293
|
+
'<div class="skeleton" style="height:180px"></div>' +
|
|
294
|
+
"</div>";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ---------- View: Übersicht ---------- */
|
|
298
|
+
|
|
299
|
+
function statValues() {
|
|
300
|
+
const engines = state.status;
|
|
301
|
+
const searches = engines.reduce((n, e) => n + (e.used?.search || 0), 0);
|
|
302
|
+
const fetches = engines.reduce((n, e) => n + (e.used?.fetch || 0), 0);
|
|
303
|
+
const h = state.history;
|
|
304
|
+
const fails = h.filter((e) => !e.ok).length;
|
|
305
|
+
const errRate = h.length ? Math.round((100 * fails) / h.length) : 0;
|
|
306
|
+
const okDurs = h.filter((e) => e.ok).map((e) => e.ms);
|
|
307
|
+
const avg = okDurs.length ? okDurs.reduce((a, b) => a + b, 0) / okDurs.length : 0;
|
|
308
|
+
return { searches, fetches, errRate: h.length ? errRate + "%" : "–", avg: okDurs.length ? fmtMs(avg) : "–" };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function rotationHtml() {
|
|
312
|
+
const enabled = sortedEngines().filter((e) => e.enabled && e.capabilities?.includes("search"));
|
|
313
|
+
if (!enabled.length) return '<div class="empty" style="padding:22px"><span class="empty-title">' + esc(t("rotation.empty")) + '</span></div>';
|
|
314
|
+
return enabled.map((e, i) => '<a class="rot-row" href="#/engines">' +
|
|
315
|
+
'<span class="rot-pos">' + (i + 1) + '</span>' + engineLogoHtml(e) +
|
|
316
|
+
'<span class="rot-content"><span class="rot-name">' + esc(e.label || e.id) + '</span>' + usageHtml(e) + budgetHtml(e) + '</span>' +
|
|
317
|
+
'<svg class="rot-arrow" width="15" height="15"><use href="#i-arrow"/></svg></a>').join('');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function healthHtml() {
|
|
321
|
+
return sortedEngines().map(e => '<a class="health-card' + (e.enabled ? '' : ' off') + '" href="#/engines">' +
|
|
322
|
+
ringGauge(remainingPctOf(e)) + '<span class="health-meta"><span class="health-name">' + esc(e.label || e.id) +
|
|
323
|
+
(!e.enabled ? ' <span class="badge">' + esc(t("health.off")) + '</span>' : '') + '</span>' +
|
|
324
|
+
usageHtml(e) + '<span class="health-src" title="' + esc(t(e.quota?.source === 'remote' ? 'quota.remoteHelp' : 'quota.localHelp')) + '">' + esc(quotaDescription(e)) + '</span>' +
|
|
325
|
+
(e.remoteError ? '<span class="quota-err" title="' + esc(e.remoteError) + '">' + esc(t("quota.fetchError")) + '</span>' : '') +
|
|
326
|
+
'</span></a>').join('');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function recentHtml() {
|
|
330
|
+
const h = state.history;
|
|
331
|
+
if (!h.length) return '<div class="empty" style="padding:20px"><span class="muted">' + esc(t("recent.empty")) + "</span></div>";
|
|
332
|
+
return h
|
|
333
|
+
.slice(0, 6)
|
|
334
|
+
.map(
|
|
335
|
+
(e) =>
|
|
336
|
+
'<a class="recent-row" href="#/history" title="' + esc(e.input + ' · ' + new Date(e.ts).toLocaleString(lang) + ' · ' + fmtMs(e.ms)) + '">' +
|
|
337
|
+
'<span class="hkind-icon ' + esc(e.kind) + '" style="width:24px;height:24px"><svg width="13" height="13"><use href="#i-' + (e.kind === "search" ? "search" : "link") + '"/></svg></span>' +
|
|
338
|
+
'<span class="ri">' + esc(e.input) + "</span>" +
|
|
339
|
+
(e.ok ? "" : '<svg width="13" height="13" style="color:var(--crit)"><use href="#i-x"/></svg>') +
|
|
340
|
+
'<span class="recent-engine">' + esc(e.engine ? statusOf(e.engine).label || e.engine : t('hist.failed')) + '</span>' +
|
|
341
|
+
'<span class="rt">' + relTime(e.ts) + "</span></a>",
|
|
342
|
+
)
|
|
343
|
+
.join("");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function renderOverview() {
|
|
347
|
+
const v = statValues();
|
|
348
|
+
$("#view").innerHTML =
|
|
349
|
+
'<div class="page">' +
|
|
350
|
+
pageHead(t("overview.title"), t("overview.sub"), state.meta.month) +
|
|
351
|
+
(anyKeySet() ? "" : '<div class="banner"><svg width="16" height="16"><use href="#i-alert"/></svg><span>' + esc(t("notice.nokeys")) + "</span></div>") +
|
|
352
|
+
'<div class="overview-grid">' +
|
|
353
|
+
statCard("searches", "search", t("stat.searches"), t("stat.searchesSub"), v.searches) +
|
|
354
|
+
statCard("fetches", "link", t("stat.fetches"), t("stat.fetchesSub"), v.fetches) +
|
|
355
|
+
statCard("errors", "alert", t("stat.errors"), t("stat.errorsSub"), v.errRate) +
|
|
356
|
+
statCard("duration", "zap", t("stat.duration"), t("stat.durationSub"), v.avg) +
|
|
357
|
+
"</div>" +
|
|
358
|
+
'<div class="two-col">' +
|
|
359
|
+
'<div class="panel"><div class="panel-head"><h2>' + esc(t("rotation.title")) + '</h2><span class="sub">' + esc(t("rotation.sub")) + '</span><span class="spacer"><a class="btn btn-ghost" href="#/engines">' + esc(t("rotation.manage")) + ' <svg width="13" height="13"><use href="#i-arrow"/></svg></a></span></div><div class="rot-list">' + rotationHtml() + "</div></div>" +
|
|
360
|
+
'<div class="panel"><div class="panel-head"><h2>' + esc(t("health.title")) + '</h2><span class="sub">' + esc(t("health.sub")) + "</span></div>" + '<div class="health-grid">' + healthHtml() + "</div></div>" +
|
|
361
|
+
"</div>" +
|
|
362
|
+
'<div class="panel"><div class="panel-head"><h2>' + esc(t("recent.title")) + '</h2><span class="spacer"><a class="btn btn-ghost" href="#/history">' + esc(t("recent.all")) + ' <svg width="13" height="13"><use href="#i-arrow"/></svg></a></span></div>' +
|
|
363
|
+
'<div class="sparkline">' + sparkline(state.history) + "</div>" +
|
|
364
|
+
'<div class="recent-list">' + recentHtml() + "</div></div>" +
|
|
365
|
+
'<details class="panel setup-panel" open><summary>' + esc(t("setup.summary")) + '<svg class="chev" width="16" height="16"><use href="#i-chevron"/></svg></summary>' +
|
|
366
|
+
'<div class="snippets">' +
|
|
367
|
+
'<div class="snippet"><h3>' + esc(t("snippet.codex")) + '</h3><pre id="snip-codex"></pre><button class="btn" data-copy="snip-codex">' + esc(t("btn.copy")) + "</button></div>" +
|
|
368
|
+
'<div class="snippet"><h3>' + esc(t("snippet.claude")) + '</h3><pre id="snip-claude"></pre><button class="btn" data-copy="snip-claude">' + esc(t("btn.copy")) + "</button></div>" +
|
|
369
|
+
'<div class="snippet"><h3>' + esc(t("snippet.remote")) + '</h3><pre id="snip-remote"></pre><button class="btn" data-copy="snip-remote">' + esc(t("btn.copy")) + "</button></div>" +
|
|
370
|
+
"</div></details>" +
|
|
371
|
+
"</div>";
|
|
372
|
+
|
|
373
|
+
renderSnippets();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Patcht nur die dynamischen Bereiche der Übersicht — kein Voll-Render, kein Flackern. */
|
|
377
|
+
function updateOverview() {
|
|
378
|
+
const root = $("#view");
|
|
379
|
+
if (!root.querySelector(".rot-list")) return;
|
|
380
|
+
const month = root.querySelector(".page-head .badge");
|
|
381
|
+
if (month) month.textContent = state.meta.month;
|
|
382
|
+
const v = statValues();
|
|
383
|
+
const set = (id, val) => {
|
|
384
|
+
const el = root.querySelector('[data-stat="' + id + '"]');
|
|
385
|
+
if (el && el.textContent !== String(val)) el.textContent = String(val);
|
|
386
|
+
};
|
|
387
|
+
set("searches", v.searches);
|
|
388
|
+
set("fetches", v.fetches);
|
|
389
|
+
set("errors", v.errRate);
|
|
390
|
+
set("duration", v.avg);
|
|
391
|
+
const rot = root.querySelector(".rot-list");
|
|
392
|
+
if (rot) rot.innerHTML = rotationHtml();
|
|
393
|
+
const health = root.querySelector(".health-grid");
|
|
394
|
+
if (health) health.innerHTML = healthHtml();
|
|
395
|
+
const spark = root.querySelector(".sparkline");
|
|
396
|
+
if (spark && !spark.contains(document.activeElement) && !spark.matches(":hover")) spark.innerHTML = sparkline(state.history);
|
|
397
|
+
const recent = root.querySelector(".recent-list");
|
|
398
|
+
if (recent) recent.innerHTML = recentHtml();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function pageHead(title, sub, month) {
|
|
402
|
+
return (
|
|
403
|
+
'<div class="page-head"><h1>' + esc(title) + (month ? ' <span class="badge">' + esc(month) + "</span>" : "") + "</h1>" +
|
|
404
|
+
'<div class="sub">' + esc(sub) + "</div></div>"
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function statCard(stat, icon, label, sub, value) {
|
|
409
|
+
return (
|
|
410
|
+
'<div class="panel stat-card" tabindex="0" title="' + esc(t('stat.' + stat + 'Help')) + '"><span class="stat-icon"><svg width="16" height="16"><use href="#i-' + icon + '"/></svg></span>' +
|
|
411
|
+
'<span class="stat-value" data-stat="' + stat + '">' + esc(value) + '</span><span class="stat-label">' + esc(label) + '</span><span class="stat-sub">' + esc(sub) + "</span></div>"
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function remainingPctOf(st) {
|
|
416
|
+
const q = st.quota;
|
|
417
|
+
return q?.limit > 0 && Number.isFinite(q.used)
|
|
418
|
+
? Math.max(0, Math.min(100, Math.round(100 * (q.limit - q.used) / q.limit))) : null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Rolling 48-hour window, based on at most 200 retained tool calls. */
|
|
422
|
+
function activityBuckets(entries, now = Date.now()) {
|
|
423
|
+
const bucketMs = 2 * 3600 * 1000;
|
|
424
|
+
const buckets = Array.from({ length: 24 }, (_, i) => ({
|
|
425
|
+
start: now - (24 - i) * bucketMs, end: now - (23 - i) * bucketMs,
|
|
426
|
+
total: 0, search: 0, fetch: 0, errors: 0, attempts: 0, engines: {},
|
|
427
|
+
}));
|
|
428
|
+
for (const e of entries) {
|
|
429
|
+
const age = now - new Date(e.ts).getTime();
|
|
430
|
+
if (!Number.isFinite(age) || age < 0 || age >= 24 * bucketMs) continue;
|
|
431
|
+
const bucket = buckets[23 - Math.floor(age / bucketMs)];
|
|
432
|
+
bucket.total++;
|
|
433
|
+
if (e.kind === 'search') bucket.search++;
|
|
434
|
+
if (e.kind === 'fetch') bucket.fetch++;
|
|
435
|
+
if (!e.ok) bucket.errors++;
|
|
436
|
+
const attempts = e.attempts ?? (e.engine ? [{ engine: e.engine }] : []);
|
|
437
|
+
for (const attempt of attempts) {
|
|
438
|
+
bucket.attempts++;
|
|
439
|
+
bucket.engines[attempt.engine] = (bucket.engines[attempt.engine] || 0) + 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return buckets;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function sparkline(entries) {
|
|
446
|
+
const buckets = activityBuckets(entries);
|
|
447
|
+
const max = Math.max(...buckets.map(b => b.total), 1);
|
|
448
|
+
const format = ts => new Date(ts).toLocaleString(lang, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
449
|
+
const bars = buckets.map(b => {
|
|
450
|
+
const providers = Object.entries(b.engines).map(([id, n]) => (statusOf(id).label || id) + ': ' + n).join(' · ');
|
|
451
|
+
const tip = format(b.start) + ' – ' + format(b.end) + '\n' + t('activity.bucket', b) +
|
|
452
|
+
(providers ? '\n' + t('activity.providers', { n: b.attempts }) + ': ' + providers : '');
|
|
453
|
+
return '<button type="button" class="activity-bucket" aria-label="' + esc(tip) + '">' +
|
|
454
|
+
'<span class="activity-fill" style="height:' + (b.total ? Math.max(5, b.total / max * 100) : 0) + '%"></span>' +
|
|
455
|
+
'<span class="activity-tooltip" aria-hidden="true">' + esc(tip) + '</span></button>';
|
|
456
|
+
}).join('');
|
|
457
|
+
return '<div class="activity-caption">' + esc(t('activity.caption')) + '</div>' +
|
|
458
|
+
'<div class="activity-bars" role="group" aria-label="' + esc(t('activity.caption')) + '">' + bars + '</div>' +
|
|
459
|
+
'<div class="activity-axis"><span>' + esc(t('activity.start')) + '</span><span>' + esc(t('activity.end')) + '</span></div>' +
|
|
460
|
+
'<p class="activity-note">' + esc(t('activity.sample', { n: entries.length })) + '</p>';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function renderSnippets() {
|
|
464
|
+
const remoteUrl = location.origin + "/mcp";
|
|
465
|
+
const packageRef = "github:localfoundry/search-rotation-mcp#v" + state.meta.version;
|
|
466
|
+
const args = ["-y", "--allow-git=all", packageRef];
|
|
467
|
+
const codex = $("#snip-codex");
|
|
468
|
+
if (!codex) return;
|
|
469
|
+
codex.textContent = '[mcp_servers.search-rotation]\ncommand = "npx"\nargs = ' + JSON.stringify(args);
|
|
470
|
+
$("#snip-claude").textContent = JSON.stringify(
|
|
471
|
+
{ mcpServers: { "search-rotation": { command: "npx", args } } },
|
|
472
|
+
null,
|
|
473
|
+
2,
|
|
474
|
+
);
|
|
475
|
+
$("#snip-remote").textContent = "URL: " + remoteUrl + "\n" + "Authorization: Bearer <token>";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* ---------- View: MCP tools ---------- */
|
|
479
|
+
|
|
480
|
+
function renderTools() {
|
|
481
|
+
const tools = [
|
|
482
|
+
{ name: "web_search", icon: "search", params: [["query", "query"], ["numResults", "count"], ["engine", "engine"], ["timeRange", "timeRange"], ["startDate", "startDate"], ["endDate", "endDate"]], args: { query: "latest AI research", numResults: 5, timeRange: "week" } },
|
|
483
|
+
{ name: "fetch_url", icon: "link", params: [["url", "url"]], args: { url: "https://example.com" } },
|
|
484
|
+
{ name: "engine_status", icon: "gauge", params: [], args: {} },
|
|
485
|
+
{ name: "open_dashboard", icon: "stack", params: [], args: {} },
|
|
486
|
+
];
|
|
487
|
+
$("#view").innerHTML = '<div class="page">' + pageHead(t("tools.title"), t("tools.sub")) +
|
|
488
|
+
'<p class="tools-intro">' + esc(t("tools.intro")) + '</p><div class="tools-grid">' + tools.map(tool =>
|
|
489
|
+
'<article class="panel tool-card"><div class="panel-head"><svg width="20" height="20" aria-hidden="true"><use href="#i-' + tool.icon + '"/></svg><h2><code>' + tool.name + '</code></h2></div>' +
|
|
490
|
+
'<div class="tool-body"><p>' + esc(t("tools." + tool.name)) + '</p><h3>' + esc(t("tools.parameters")) + '</h3>' +
|
|
491
|
+
(tool.params.length ? '<dl class="tool-params">' + tool.params.map(([name, key]) => '<dt><code>' + name + '</code></dt><dd>' + esc(t("tools.param." + key)) + '</dd>').join('') + '</dl>' : '<p class="muted">' + esc(t("tools.none")) + '</p>') +
|
|
492
|
+
'<div class="tool-example"><h3>' + esc(t("tools.example")) + '</h3><pre id="tool-example-' + tool.name + '">' + esc(JSON.stringify({ name: tool.name, arguments: tool.args }, null, 2)) + '</pre>' +
|
|
493
|
+
'<button class="btn" data-copy="tool-example-' + tool.name + '" aria-label="' + esc(t("tools.copy", { name: tool.name })) + '">' + esc(t("btn.copy")) + '</button></div></div></article>'
|
|
494
|
+
).join('') + '</div></div>';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/* ---------- View: Engines ---------- */
|
|
498
|
+
|
|
499
|
+
function settingsHtml() {
|
|
500
|
+
const settings = state.config.settings;
|
|
501
|
+
return '<div class="panel settings-panel"><h2>' + esc(t("settings.title")) + '</h2>' +
|
|
502
|
+
'<label class="settings-check"><input id="strict-free" type="checkbox"' + (settings.strictFreeMode ? " checked" : "") + '> ' + esc(t("settings.strict")) + '</label>' +
|
|
503
|
+
'<p class="muted">' + esc(t("settings.strictHelp")) + '</p>' +
|
|
504
|
+
'<label for="request-timeout">' + esc(t("settings.timeout")) + '</label> ' +
|
|
505
|
+
'<input id="request-timeout" type="number" min="1000" max="300000" step="1000" value="' + (settings.requestTimeoutMs ?? 60000) + '">' +
|
|
506
|
+
'<div class="result-settings"><label for="result-count-mode">' + esc(t("settings.results")) + '</label> ' +
|
|
507
|
+
'<select id="result-count-mode"><option value="custom"' + (settings.defaultNumResults !== null ? ' selected' : '') + '>' + esc(t("settings.resultsCustom")) + '</option><option value="provider"' + (settings.defaultNumResults === null ? ' selected' : '') + '>' + esc(t("settings.resultsProvider")) + '</option></select> ' +
|
|
508
|
+
'<input id="result-count" aria-label="' + esc(t("settings.resultsCount")) + '" type="number" required min="1" max="20" step="1" value="' + (settings.defaultNumResults ?? 8) + '"' + (settings.defaultNumResults === null ? ' disabled hidden' : '') + '>' +
|
|
509
|
+
'<p class="muted">' + esc(t("settings.resultsHelp")) + '</p></div>' +
|
|
510
|
+
'<button class="btn" data-act="save-settings">' + esc(t("btn.save")) + '</button></div>';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function renderEngines() {
|
|
514
|
+
const wrap = $("#view");
|
|
515
|
+
// Ungespeicherte Eingaben über das Re-Render retten
|
|
516
|
+
const saved = {};
|
|
517
|
+
const strictDraft = wrap.querySelector("#strict-free")?.checked;
|
|
518
|
+
const timeoutDraft = wrap.querySelector("#request-timeout")?.value;
|
|
519
|
+
const resultsModeDraft = wrap.querySelector("#result-count-mode")?.value;
|
|
520
|
+
const resultsCountDraft = wrap.querySelector("#result-count")?.value;
|
|
521
|
+
wrap.querySelectorAll(".keyinput").forEach((el) => {
|
|
522
|
+
if (el.value) saved["key:" + el.dataset.id] = el.value;
|
|
523
|
+
});
|
|
524
|
+
wrap.querySelectorAll(".extrainput").forEach((el) => {
|
|
525
|
+
if (el.value) saved["extra:" + el.dataset.id + ":" + el.dataset.extra] = el.value;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const engines = sortedEngines();
|
|
529
|
+
const inRotation = engines.filter((e) => e.enabled).length;
|
|
530
|
+
const keys = engines.filter((e) => e.hasKey).length;
|
|
531
|
+
|
|
532
|
+
const cards = engines
|
|
533
|
+
.map((e) => {
|
|
534
|
+
const open = state.openEngines.has(e.id);
|
|
535
|
+
const caps = (e.capabilities || []).map((c) => '<span class="badge">' + esc(t("cap." + c)) + "</span>").join("");
|
|
536
|
+
const extras = (e.extraFields || [])
|
|
537
|
+
.map(
|
|
538
|
+
(f) =>
|
|
539
|
+
'<div class="drawer-row"><input type="text" class="extrainput" data-id="' + esc(e.id) + '" data-extra="' + esc(f.key) +
|
|
540
|
+
'" placeholder="' + esc(f.label) + (e.extrasSet && e.extrasSet[f.key] ? " · " + esc(t("extra.set")) : "") + '">' +
|
|
541
|
+
'<button class="btn" data-act="save-extra">' + esc(t("btn.save")) + "</button></div>",
|
|
542
|
+
)
|
|
543
|
+
.join("");
|
|
544
|
+
return (
|
|
545
|
+
'<div class="engine-card' + (e.enabled ? "" : " off") + (open ? " open" : "") + '" data-id="' + esc(e.id) + '">' +
|
|
546
|
+
'<div class="engine-head">' +
|
|
547
|
+
'<span class="handle" draggable="true" title="Drag & Drop"><svg width="16" height="16"><use href="#i-grip"/></svg></span>' +
|
|
548
|
+
'<span class="pos-badge">' + (e.enabled ? e.searchPosition + 1 : "–") + "</span>" +
|
|
549
|
+
engineLogoHtml(e) +
|
|
550
|
+
'<span class="engine-title"><span class="engine-name">' + esc(e.label || e.id) + "</span>" +
|
|
551
|
+
'<span class="engine-badges">' +
|
|
552
|
+
(!e.hasKey && e.keyless === "ip" ? '<span class="badge ip">' + esc(t("badge.keyless")) + "</span>" : "") +
|
|
553
|
+
(e.quotaEndpoint ? '<span class="badge quota">' + esc(t("badge.quota")) + "</span>" : "") +
|
|
554
|
+
"</span></span>" +
|
|
555
|
+
'<label class="switch"><input type="checkbox" aria-label="' + esc(e.label || e.id) + '" data-act="toggle"' + (e.enabled ? " checked" : "") + '><span class="track-el"></span></label>' +
|
|
556
|
+
'<span class="engine-quota" data-quota="' + esc(e.id) + '">' + quotaHtml(e) + "</span>" +
|
|
557
|
+
'<button class="drawer-toggle" data-act="drawer" aria-expanded="' + open + '" title="' + esc(open ? t("engines.collapse") : t("engines.config")) + '"><svg width="17" height="17"><use href="#i-chevron"/></svg></button>' +
|
|
558
|
+
"</div>" +
|
|
559
|
+
'<div class="drawer">' +
|
|
560
|
+
'<div class="drawer-row">' +
|
|
561
|
+
'<input type="password" class="keyinput" aria-label="' + esc((e.label || e.id) + ": " + t("key.set")) + '" data-id="' + esc(e.id) + '" placeholder="' +
|
|
562
|
+
(e.hasKey ? esc(t("key.replace", { masked: e.keyMasked })) : esc(t("key.set"))) + '">' +
|
|
563
|
+
'<button class="btn btn-primary" data-act="save-key">' + esc(t("btn.save")) + "</button>" +
|
|
564
|
+
(e.hasKey ? '<button class="btn btn-danger" data-act="clear-key">' + esc(t("key.clear")) + "</button>" : "") +
|
|
565
|
+
"</div>" +
|
|
566
|
+
extras +
|
|
567
|
+
'<div class="drawer-meta"><span class="caps-chips">' + caps + "</span>" +
|
|
568
|
+
'<a class="signup" href="' + esc(e.signupUrl || e.homepage || "#") + '" target="_blank" rel="noreferrer">' + esc(t("signup")) + ' <svg width="12" height="12"><use href="#i-external"/></svg></a>' +
|
|
569
|
+
(e.capabilities || []).map((kind) => '<button class="btn" data-act="test" data-kind="' + kind + '"><svg width="13" height="13"><use href="#i-zap"/></svg> Test: ' + esc(t("cap." + kind)) + "</button>").join("") + "</div>" +
|
|
570
|
+
'<div class="testout" data-testout="' + esc(e.id) + '"></div>' +
|
|
571
|
+
(e.notes ? '<div class="notes">' + esc(e.notes) + "</div>" : "") +
|
|
572
|
+
"</div></div>"
|
|
573
|
+
);
|
|
574
|
+
})
|
|
575
|
+
.join("");
|
|
576
|
+
|
|
577
|
+
wrap.innerHTML =
|
|
578
|
+
'<div class="page">' +
|
|
579
|
+
pageHead(t("engines.title"), t("engines.sub")) +
|
|
580
|
+
settingsHtml() +
|
|
581
|
+
(anyKeySet() ? "" : '<div class="banner"><svg width="16" height="16"><use href="#i-alert"/></svg><span>' + esc(t("notice.nokeys")) + "</span></div>") +
|
|
582
|
+
'<div style="margin-bottom:14px"><span class="engines-stat"><span class="dot"></span>' +
|
|
583
|
+
esc(t("engines.stat", { in: inRotation, keys, total: engines.length })) +
|
|
584
|
+
"</span></div>" +
|
|
585
|
+
'<div class="engine-list">' + cards + "</div></div>";
|
|
586
|
+
|
|
587
|
+
// Gesicherte Eingaben zurückschreiben
|
|
588
|
+
if (strictDraft !== undefined) wrap.querySelector("#strict-free").checked = strictDraft;
|
|
589
|
+
if (timeoutDraft !== undefined) wrap.querySelector("#request-timeout").value = timeoutDraft;
|
|
590
|
+
if (resultsModeDraft !== undefined) {
|
|
591
|
+
wrap.querySelector("#result-count-mode").value = resultsModeDraft;
|
|
592
|
+
const field = wrap.querySelector("#result-count");
|
|
593
|
+
field.value = resultsCountDraft;
|
|
594
|
+
field.disabled = field.hidden = resultsModeDraft === "provider";
|
|
595
|
+
}
|
|
596
|
+
wrap.querySelectorAll(".keyinput").forEach((el) => {
|
|
597
|
+
const v = saved["key:" + el.dataset.id];
|
|
598
|
+
if (v) el.value = v;
|
|
599
|
+
});
|
|
600
|
+
wrap.querySelectorAll(".extrainput").forEach((el) => {
|
|
601
|
+
const v = saved["extra:" + el.dataset.id + ":" + el.dataset.extra];
|
|
602
|
+
if (v) el.value = v;
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Speichert eine Drag-Operation auf der zuletzt bestätigten Konfiguration. */
|
|
607
|
+
function queueReorder(from, to) {
|
|
608
|
+
return queuePut((cfg) => {
|
|
609
|
+
const engines = cfg.engines.map((e) => ({ id: e.id, enabled: e.enabled }));
|
|
610
|
+
const i = engines.findIndex((e) => e.id === from);
|
|
611
|
+
const j = engines.findIndex((e) => e.id === to);
|
|
612
|
+
if (i >= 0 && j >= 0) engines.splice(j, 0, engines.splice(i, 1)[0]);
|
|
613
|
+
return { engines, fetchOrder: cfg.fetchOrder };
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* ---------- View: Verlauf ---------- */
|
|
618
|
+
|
|
619
|
+
function filteredHistory() {
|
|
620
|
+
const f = state.filters;
|
|
621
|
+
const q = f.q.trim().toLowerCase();
|
|
622
|
+
return state.history.filter((e) => {
|
|
623
|
+
if (f.kind !== "all" && e.kind !== f.kind) return false;
|
|
624
|
+
if (f.engine && e.engine !== f.engine && !(e.attempts || []).some(a => a.engine === f.engine)) return false;
|
|
625
|
+
if (q) {
|
|
626
|
+
const hay = (e.input + " " + (e.engine || "") + " " + (e.attempts || []).map((a) => a.engine).join(" ")).toLowerCase();
|
|
627
|
+
if (!hay.includes(q)) return false;
|
|
628
|
+
}
|
|
629
|
+
return true;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function histListHtml(entries) {
|
|
634
|
+
return entries
|
|
635
|
+
.map((e) => {
|
|
636
|
+
const key = e.ts;
|
|
637
|
+
const failChips = (e.attempts || [])
|
|
638
|
+
.filter((a) => !a.ok)
|
|
639
|
+
.map((a) => '<span class="chip fail" title="' + esc(a.error || "") + '">' + esc(a.engine) + "</span>")
|
|
640
|
+
.join("");
|
|
641
|
+
const meta =
|
|
642
|
+
e.ok && e.kind === "search" && e.result?.count !== undefined
|
|
643
|
+
? t("hist.results", { n: e.result.count })
|
|
644
|
+
: e.ok && e.result?.chars !== undefined
|
|
645
|
+
? t("hist.chars", { n: e.result.chars })
|
|
646
|
+
: "";
|
|
647
|
+
|
|
648
|
+
let body = "";
|
|
649
|
+
if (e.error) body += '<p class="error-text">' + esc(e.error) + "</p>";
|
|
650
|
+
if ((e.attempts || []).length) {
|
|
651
|
+
body +=
|
|
652
|
+
"<p><strong>" + esc(t("hist.attempts")) + ":</strong></p><ul class='att'>" +
|
|
653
|
+
e.attempts.map((a) => "<li>" + esc(a.engine) + " — " + (a.ok ? "✓" : "✗ " + esc(a.error || "")) + " (" + fmtMs(a.ms) + ")</li>").join("") +
|
|
654
|
+
"</ul>";
|
|
655
|
+
}
|
|
656
|
+
if (e.result?.items?.length) {
|
|
657
|
+
body +=
|
|
658
|
+
"<p><strong>" + esc(t("hist.items")) + ":</strong></p><ul class='hitems'>" +
|
|
659
|
+
e.result.items
|
|
660
|
+
.map(
|
|
661
|
+
(i) =>
|
|
662
|
+
"<li><a href='" + esc(i.url) + "' target='_blank' rel='noreferrer'>" + esc(i.title || i.url) + "</a>" +
|
|
663
|
+
(i.snippet ? "<div class='snip'>" + esc(i.snippet.slice(0, 200)) + "</div>" : "") + "</li>",
|
|
664
|
+
)
|
|
665
|
+
.join("") +
|
|
666
|
+
"</ul>";
|
|
667
|
+
}
|
|
668
|
+
if (e.result?.markdown) {
|
|
669
|
+
body += "<p><strong>" + esc(t("hist.markdown")) + ":</strong></p><pre>" + esc(e.result.markdown.slice(0, 2000)) + "</pre>";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return (
|
|
673
|
+
'<details class="hist' + (e.ok ? "" : " fail") + '"' + (state.historyOpen.has(key) ? " open" : "") + ' data-key="' + esc(key) + '">' +
|
|
674
|
+
"<summary>" +
|
|
675
|
+
'<span class="hkind-icon ' + esc(e.kind) + '"><svg width="14" height="14"><use href="#i-' + (e.kind === "search" ? "search" : "link") + '"/></svg></span>' +
|
|
676
|
+
'<time class="htime" datetime="' + esc(e.ts) + '" title="' + esc(new Date(e.ts).toLocaleString(lang)) + '">' + new Date(e.ts).toLocaleTimeString(lang, { hour12: false }) + '</time>' +
|
|
677
|
+
'<span class="hinput" title="' + esc(e.input) + '">' + esc(e.input) + "</span>" +
|
|
678
|
+
'<span class="chips">' + (e.engine ? '<span class="chip ok">' + esc(e.engine) + "</span>" : "") + failChips + "</span>" +
|
|
679
|
+
'<span class="hmeta">' +
|
|
680
|
+
(e.ok ? esc(meta) + " · " + fmtMs(e.ms) : '<span class="fail-mark">' + esc(t("hist.failed")) + "</span> · " + fmtMs(e.ms)) +
|
|
681
|
+
"</span>" +
|
|
682
|
+
'<svg class="chev" width="15" height="15"><use href="#i-chevron"/></svg>' +
|
|
683
|
+
"</summary>" +
|
|
684
|
+
(body ? '<div class="histbody">' + body + "</div>" : "") +
|
|
685
|
+
"</details>"
|
|
686
|
+
);
|
|
687
|
+
})
|
|
688
|
+
.join("");
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function bindHistToggles() {
|
|
692
|
+
document.querySelectorAll(".hist").forEach((det) => {
|
|
693
|
+
det.addEventListener("toggle", () => {
|
|
694
|
+
const k = det.dataset.key;
|
|
695
|
+
if (det.open) state.historyOpen.add(k);
|
|
696
|
+
else state.historyOpen.delete(k);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Engine-Filter als Custom-Dropdown — native <select>-Popups folgen nicht dem
|
|
702
|
+
* Seiten-Theme (helle System-Popups mit heller Schrift = unlesbar im Dark). */
|
|
703
|
+
function engineDropdownHtml(selected) {
|
|
704
|
+
const engines = sortedEngines();
|
|
705
|
+
const cur = engines.find((e) => e.id === selected);
|
|
706
|
+
const label = selected && cur ? cur.label || cur.id : t("hist.filter.all");
|
|
707
|
+
const items =
|
|
708
|
+
'<button role="menuitem" data-engine="" class="' + (!selected ? "active" : "") + '">' + esc(t("hist.filter.all")) +
|
|
709
|
+
'<svg class="mi-check" width="14" height="14"><use href="#i-check"/></svg></button>' +
|
|
710
|
+
engines
|
|
711
|
+
.map(
|
|
712
|
+
(e) =>
|
|
713
|
+
'<button role="menuitem" data-engine="' + esc(e.id) + '" class="' + (selected === e.id ? "active" : "") + '">' +
|
|
714
|
+
esc(e.label || e.id) + '<svg class="mi-check" width="14" height="14"><use href="#i-check"/></svg></button>',
|
|
715
|
+
)
|
|
716
|
+
.join("");
|
|
717
|
+
return (
|
|
718
|
+
'<div class="dropdown" id="engine-dd">' +
|
|
719
|
+
'<button class="btn" id="engine-dd-btn" aria-haspopup="menu" aria-expanded="false">' +
|
|
720
|
+
'<svg width="13" height="13"><use href="#i-stack"/></svg><span id="engine-dd-label">' + esc(label) + "</span>" +
|
|
721
|
+
'<svg class="chev" width="13" height="13"><use href="#i-chevron"/></svg></button>' +
|
|
722
|
+
'<div class="menu menu-left" id="engine-dd-menu" role="menu" hidden>' + items + "</div></div>"
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function historyEmptyHtml() {
|
|
727
|
+
return '<div class="empty"><span class="empty-title">' + esc(t(state.history.length ? "history.noMatches" : "history.empty")) + "</span></div>";
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function renderHistory() {
|
|
731
|
+
const f = state.filters;
|
|
732
|
+
const entries = filteredHistory();
|
|
733
|
+
const fails = entries.filter((e) => !e.ok).length;
|
|
734
|
+
|
|
735
|
+
$("#view").innerHTML =
|
|
736
|
+
'<div class="page">' +
|
|
737
|
+
pageHead(t("history.title"), t("history.sub")) +
|
|
738
|
+
'<div class="hist-toolbar">' +
|
|
739
|
+
'<div class="seg" role="tablist">' +
|
|
740
|
+
segBtn("all", t("hist.filter.all")) +
|
|
741
|
+
segBtn("search", t("hist.filter.search")) +
|
|
742
|
+
segBtn("fetch", t("hist.filter.fetch")) +
|
|
743
|
+
"</div>" +
|
|
744
|
+
engineDropdownHtml(f.engine) +
|
|
745
|
+
'<input type="text" id="hist-q" placeholder="' + esc(t("hist.search.ph")) + '" value="' + esc(f.q) + '">' +
|
|
746
|
+
'<span class="spacer"></span>' +
|
|
747
|
+
'<span class="live-dot' + (state.livePaused ? " paused" : "") + '" id="live-dot"><span class="pulse"></span></span>' +
|
|
748
|
+
'<button class="btn" data-act="live-toggle">' + esc(state.livePaused ? t("hist.resume") : t("hist.pause")) + "</button>" +
|
|
749
|
+
'<button class="btn btn-danger" data-act="hist-clear"><svg width="13" height="13"><use href="#i-trash"/></svg> ' + esc(t("history.clear")) + "</button>" +
|
|
750
|
+
"</div>" +
|
|
751
|
+
'<p class="hist-summary">' + esc(t("hist.shown", { n: entries.length, f: fails })) + "</p>" +
|
|
752
|
+
'<div class="hist-list">' + (entries.length ? histListHtml(entries) : historyEmptyHtml()) + "</div>" +
|
|
753
|
+
"</div>";
|
|
754
|
+
|
|
755
|
+
bindHistToggles();
|
|
756
|
+
|
|
757
|
+
const qInput = $("#hist-q");
|
|
758
|
+
qInput.addEventListener("input", () => {
|
|
759
|
+
state.filters.q = qInput.value;
|
|
760
|
+
clearTimeout(qInput.__t);
|
|
761
|
+
qInput.__t = setTimeout(updateHistory, 220);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const ddBtn = $("#engine-dd-btn");
|
|
765
|
+
const ddMenu = $("#engine-dd-menu");
|
|
766
|
+
ddBtn.addEventListener("click", () => {
|
|
767
|
+
const open = !ddMenu.hidden;
|
|
768
|
+
ddMenu.hidden = open;
|
|
769
|
+
ddBtn.setAttribute("aria-expanded", String(!open));
|
|
770
|
+
});
|
|
771
|
+
ddMenu.addEventListener("click", (ev) => {
|
|
772
|
+
const item = ev.target.closest("[data-engine]");
|
|
773
|
+
if (!item) return;
|
|
774
|
+
state.filters.engine = item.dataset.engine;
|
|
775
|
+
$("#engine-dd-label").textContent = item.textContent.trim();
|
|
776
|
+
ddMenu.hidden = true;
|
|
777
|
+
ddBtn.setAttribute("aria-expanded", "false");
|
|
778
|
+
ddMenu.querySelectorAll("[data-engine]").forEach((b) => b.classList.toggle("active", b.dataset.engine === state.filters.engine));
|
|
779
|
+
updateHistory();
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Patcht nur Liste + Zusammenfassung — Toolbar und Fokus im Filterfeld bleiben unangetastet. */
|
|
784
|
+
function updateHistory() {
|
|
785
|
+
const root = $("#view");
|
|
786
|
+
const list = root.querySelector(".hist-list");
|
|
787
|
+
if (!list) {
|
|
788
|
+
// Übergang vom Empty-State zu ersten Einträgen → einmal voll rendern
|
|
789
|
+
if (filteredHistory().length && root.querySelector(".empty")) renderHistory();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const entries = filteredHistory();
|
|
793
|
+
const fails = entries.filter((e) => !e.ok).length;
|
|
794
|
+
const summary = root.querySelector(".hist-summary");
|
|
795
|
+
if (summary) summary.textContent = t("hist.shown", { n: entries.length, f: fails });
|
|
796
|
+
list.innerHTML = entries.length ? histListHtml(entries) : historyEmptyHtml();
|
|
797
|
+
bindHistToggles();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function segBtn(kind, label) {
|
|
801
|
+
return '<button data-kind="' + kind + '"' + (state.filters.kind === kind ? ' class="active"' : "") + ">" + esc(label) + "</button>";
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/* ---------- Drag & Drop (delegiert, einmalig) ---------- */
|
|
805
|
+
|
|
806
|
+
document.addEventListener("dragstart", (ev) => {
|
|
807
|
+
const card = ev.target.closest?.(".engine-card");
|
|
808
|
+
if (!card) return;
|
|
809
|
+
if (!ev.target.closest(".handle")) {
|
|
810
|
+
// Drag nur am Griff starten — Inputs/Buttons bleiben normal nutzbar
|
|
811
|
+
ev.preventDefault();
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
state.dragFrom = card.dataset.id;
|
|
815
|
+
card.classList.add("dragging");
|
|
816
|
+
ev.dataTransfer.effectAllowed = "move";
|
|
817
|
+
try {
|
|
818
|
+
ev.dataTransfer.setData("text/plain", card.dataset.id);
|
|
819
|
+
} catch {}
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
document.addEventListener("dragend", (ev) => {
|
|
823
|
+
const card = ev.target.closest?.(".engine-card");
|
|
824
|
+
if (card) card.classList.remove("dragging");
|
|
825
|
+
document.querySelectorAll(".drag-over").forEach((x) => x.classList.remove("drag-over"));
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
document.addEventListener("dragover", (ev) => {
|
|
829
|
+
const card = ev.target.closest?.(".engine-card");
|
|
830
|
+
if (!card || !state.dragFrom) return;
|
|
831
|
+
ev.preventDefault();
|
|
832
|
+
document.querySelectorAll(".drag-over").forEach((x) => x.classList.remove("drag-over"));
|
|
833
|
+
card.classList.add("drag-over");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
document.addEventListener("drop", (ev) => {
|
|
837
|
+
const toEl = ev.target.closest?.(".engine-card");
|
|
838
|
+
const from = state.dragFrom;
|
|
839
|
+
state.dragFrom = null;
|
|
840
|
+
document.querySelectorAll(".drag-over").forEach((x) => x.classList.remove("drag-over"));
|
|
841
|
+
ev.preventDefault();
|
|
842
|
+
if (!toEl || !from || from === toEl.dataset.id || state.route !== "/engines") return;
|
|
843
|
+
const container = $(".engine-list");
|
|
844
|
+
if (!container) return;
|
|
845
|
+
queueReorder(from, toEl.dataset.id);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
/* ---------- Events (delegiert) ---------- */
|
|
849
|
+
|
|
850
|
+
document.addEventListener("change", (ev) => {
|
|
851
|
+
if (!ev.target.matches || !ev.target.matches('input[data-act="toggle"]')) return;
|
|
852
|
+
const id = ev.target.closest("[data-id]").dataset.id;
|
|
853
|
+
const enabled = ev.target.checked;
|
|
854
|
+
queuePut((cfg) => ({
|
|
855
|
+
engines: cfg.engines.map((e) => (e.id === id ? { id, enabled } : { id: e.id, enabled: e.enabled })),
|
|
856
|
+
fetchOrder: cfg.fetchOrder,
|
|
857
|
+
settings: { port: cfg.settings.port },
|
|
858
|
+
}));
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
document.addEventListener("click", async (ev) => {
|
|
862
|
+
if (ev.target.closest('[data-act="save-settings"]')) {
|
|
863
|
+
const timeoutInput = $("#request-timeout");
|
|
864
|
+
if (!timeoutInput.checkValidity()) { timeoutInput.reportValidity(); return; }
|
|
865
|
+
const providerDefault = $("#result-count-mode").value === "provider";
|
|
866
|
+
const countInput = $("#result-count");
|
|
867
|
+
if (!providerDefault && !countInput.checkValidity()) { countInput.reportValidity(); return; }
|
|
868
|
+
const defaultNumResults = providerDefault ? null : Number(countInput.value);
|
|
869
|
+
const strictFreeMode = $("#strict-free").checked;
|
|
870
|
+
const requestTimeoutMs = Number(timeoutInput.value);
|
|
871
|
+
await queuePut((cfg) => ({
|
|
872
|
+
engines: cfg.engines.map((e) => ({ id: e.id, enabled: e.enabled })),
|
|
873
|
+
settings: { strictFreeMode, requestTimeoutMs, defaultNumResults },
|
|
874
|
+
}));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Kopieren-Buttons
|
|
879
|
+
const copyBtn = ev.target.closest("button[data-copy]");
|
|
880
|
+
if (copyBtn) {
|
|
881
|
+
navigator.clipboard.writeText(document.getElementById(copyBtn.dataset.copy).textContent);
|
|
882
|
+
copyBtn.textContent = t("btn.copied");
|
|
883
|
+
toast("info", t("toast.copied"));
|
|
884
|
+
setTimeout(() => (copyBtn.textContent = t("btn.copy")), 1500);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Verlauf löschen
|
|
889
|
+
const clearBtn = ev.target.closest('[data-act="hist-clear"]');
|
|
890
|
+
if (clearBtn) {
|
|
891
|
+
if (confirm(t("history.confirm"))) {
|
|
892
|
+
try {
|
|
893
|
+
await api("/api/history", { method: "DELETE" });
|
|
894
|
+
state.historyOpen.clear();
|
|
895
|
+
state.history = [];
|
|
896
|
+
renderHistory();
|
|
897
|
+
toast("ok", t("toast.saved"));
|
|
898
|
+
} catch (err) {
|
|
899
|
+
if (err.message !== "unauthorized") toast("error", t("toast.error", { msg: err.message }));
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Live pausieren/fortsetzen
|
|
906
|
+
if (ev.target.closest('[data-act="live-toggle"]')) {
|
|
907
|
+
state.livePaused = !state.livePaused;
|
|
908
|
+
renderHistory();
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Segment-Filter
|
|
913
|
+
const seg = ev.target.closest(".seg [data-kind]");
|
|
914
|
+
if (seg) {
|
|
915
|
+
state.filters.kind = seg.dataset.kind;
|
|
916
|
+
renderHistory();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Drawer auf/zu
|
|
921
|
+
const drawerBtn = ev.target.closest('[data-act="drawer"]');
|
|
922
|
+
if (drawerBtn) {
|
|
923
|
+
const id = drawerBtn.closest("[data-id]").dataset.id;
|
|
924
|
+
if (state.openEngines.has(id)) state.openEngines.delete(id);
|
|
925
|
+
else state.openEngines.add(id);
|
|
926
|
+
renderEngines();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Engine-Aktionen
|
|
931
|
+
const btn = ev.target.closest("button[data-act]");
|
|
932
|
+
if (!btn) return;
|
|
933
|
+
const act = btn.dataset.act;
|
|
934
|
+
if (!["save-key", "clear-key", "save-extra", "test"].includes(act)) return;
|
|
935
|
+
const host = btn.closest("[data-id]");
|
|
936
|
+
if (!host) return;
|
|
937
|
+
const id = host.dataset.id;
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
if (act === "save-key") {
|
|
941
|
+
const value = host.querySelector(".keyinput").value;
|
|
942
|
+
queuePut((cfg) => ({
|
|
943
|
+
engines: cfg.engines.map((e) => (e.id === id ? { id, enabled: e.enabled, apiKey: value } : { id: e.id, enabled: e.enabled })),
|
|
944
|
+
fetchOrder: cfg.fetchOrder,
|
|
945
|
+
settings: { port: cfg.settings.port },
|
|
946
|
+
}));
|
|
947
|
+
} else if (act === "clear-key") {
|
|
948
|
+
queuePut((cfg) => ({
|
|
949
|
+
engines: cfg.engines.map((e) => (e.id === id ? { id, enabled: e.enabled, apiKey: null } : { id: e.id, enabled: e.enabled })),
|
|
950
|
+
fetchOrder: cfg.fetchOrder,
|
|
951
|
+
settings: { port: cfg.settings.port },
|
|
952
|
+
}));
|
|
953
|
+
} else if (act === "save-extra") {
|
|
954
|
+
const input = host.querySelector('.extrainput[data-extra="' + CSS.escape(btn.closest(".drawer-row").querySelector(".extrainput").dataset.extra) + '"]');
|
|
955
|
+
const key = input.dataset.extra;
|
|
956
|
+
const value = input.value.trim();
|
|
957
|
+
queuePut((cfg) => ({
|
|
958
|
+
engines: cfg.engines.map((e) =>
|
|
959
|
+
e.id === id ? { id, enabled: e.enabled, extra: { [key]: value || null } } : { id: e.id, enabled: e.enabled },
|
|
960
|
+
),
|
|
961
|
+
fetchOrder: cfg.fetchOrder,
|
|
962
|
+
settings: { port: cfg.settings.port },
|
|
963
|
+
}));
|
|
964
|
+
} else if (act === "test") {
|
|
965
|
+
const out = document.querySelector('[data-testout="' + CSS.escape(id) + '"]');
|
|
966
|
+
out.innerHTML = '<span class="muted">' + esc(t("test.running")) + "</span>";
|
|
967
|
+
const capabilities = statusOf(id).capabilities || [];
|
|
968
|
+
const kind = capabilities.includes(btn.dataset.kind) ? btn.dataset.kind : capabilities[0];
|
|
969
|
+
if (!kind) throw new Error("Keine testbare Fähigkeit");
|
|
970
|
+
const r = await api("/api/test", { method: "POST", body: { id, kind } });
|
|
971
|
+
if (r.ok) {
|
|
972
|
+
out.innerHTML =
|
|
973
|
+
'<span class="ok">✓ ' + esc(kind === "fetch" ? t("test.fetchOk", { chars: r.chars, ms: fmtMs(r.ms) }) : t("test.ok", { count: r.count, ms: fmtMs(r.ms) })) + "</span>" +
|
|
974
|
+
(r.preview ? "<pre>" + esc(r.preview) + "</pre>" : "");
|
|
975
|
+
} else {
|
|
976
|
+
out.innerHTML = '<span class="error">✗ ' + esc(r.error) + "</span>";
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
} catch (err) {
|
|
980
|
+
if (err.message !== "unauthorized") toast("error", t("toast.error", { msg: err.message }));
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
/* ---------- Sprach-Dropdown & Theme-Button ---------- */
|
|
985
|
+
|
|
986
|
+
const langBtn = $("#lang-btn");
|
|
987
|
+
const langMenu = $("#lang-menu");
|
|
988
|
+
|
|
989
|
+
langBtn.addEventListener("click", (ev) => {
|
|
990
|
+
ev.stopPropagation();
|
|
991
|
+
const open = !langMenu.hidden;
|
|
992
|
+
langMenu.hidden = open;
|
|
993
|
+
langBtn.setAttribute("aria-expanded", String(!open));
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
langMenu.addEventListener("click", (ev) => {
|
|
997
|
+
const item = ev.target.closest("[data-lang]");
|
|
998
|
+
if (!item) return;
|
|
999
|
+
lang = item.dataset.lang;
|
|
1000
|
+
localStorage.setItem("sr_lang", lang);
|
|
1001
|
+
langMenu.hidden = true;
|
|
1002
|
+
langBtn.setAttribute("aria-expanded", "false");
|
|
1003
|
+
applyI18n();
|
|
1004
|
+
setTheme(document.documentElement.dataset.theme, false);
|
|
1005
|
+
render();
|
|
1006
|
+
if (state.route === "/history" || state.route === "/") loadHistory().then(render).catch(() => {});
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
document.addEventListener("click", (ev) => {
|
|
1010
|
+
if (!langMenu.hidden && !ev.target.closest("#lang")) {
|
|
1011
|
+
langMenu.hidden = true;
|
|
1012
|
+
langBtn.setAttribute("aria-expanded", "false");
|
|
1013
|
+
}
|
|
1014
|
+
const engMenu = $("#engine-dd-menu");
|
|
1015
|
+
if (engMenu && !engMenu.hidden && !ev.target.closest("#engine-dd")) {
|
|
1016
|
+
engMenu.hidden = true;
|
|
1017
|
+
$("#engine-dd-btn")?.setAttribute("aria-expanded", "false");
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
document.addEventListener("keydown", (ev) => {
|
|
1022
|
+
if (ev.key !== "Escape") return;
|
|
1023
|
+
if (!langMenu.hidden) {
|
|
1024
|
+
langMenu.hidden = true;
|
|
1025
|
+
langBtn.setAttribute("aria-expanded", "false");
|
|
1026
|
+
}
|
|
1027
|
+
const engMenu = $("#engine-dd-menu");
|
|
1028
|
+
if (engMenu && !engMenu.hidden) {
|
|
1029
|
+
engMenu.hidden = true;
|
|
1030
|
+
$("#engine-dd-btn")?.setAttribute("aria-expanded", "false");
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
$("#theme-btn").addEventListener("click", () => {
|
|
1035
|
+
setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
/* ---------- Polling ---------- */
|
|
1039
|
+
|
|
1040
|
+
/* ---------- Polling (gezielte Teil-Updates, kein Voll-Render) ---------- */
|
|
1041
|
+
|
|
1042
|
+
let lastHistorySig = "";
|
|
1043
|
+
function historySig() {
|
|
1044
|
+
const h = state.history;
|
|
1045
|
+
// Minuten-Bucket: relative Zeiten ("vor 1 Stunde") bleiben ohne Flackern frisch
|
|
1046
|
+
return h.length + ":" + (h[0] ? h[0].ts : "") + ":" + Math.floor(Date.now() / 60000);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
let lastStatusSig = "";
|
|
1050
|
+
function statusSig() {
|
|
1051
|
+
return JSON.stringify([state.meta?.month, state.status.map(s => [s.id, s.enabled, s.searchPosition, s.capabilities, s.used, s.quota, s.remoteError])]);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
setInterval(() => {
|
|
1055
|
+
if (document.hidden || state.unauthorized || state.livePaused) return;
|
|
1056
|
+
if (state.route !== "/history" && state.route !== "/") return;
|
|
1057
|
+
loadHistory()
|
|
1058
|
+
.then(() => {
|
|
1059
|
+
const sig = historySig();
|
|
1060
|
+
if (sig === lastHistorySig) return; // nichts Neues → kein DOM-Write, kein Flackern
|
|
1061
|
+
lastHistorySig = sig;
|
|
1062
|
+
if (state.route === "/history") updateHistory();
|
|
1063
|
+
else if (state.route === "/") updateOverview();
|
|
1064
|
+
})
|
|
1065
|
+
.catch(() => {});
|
|
1066
|
+
}, 5000);
|
|
1067
|
+
|
|
1068
|
+
setInterval(async () => {
|
|
1069
|
+
if (document.hidden || state.unauthorized) return;
|
|
1070
|
+
try {
|
|
1071
|
+
await refreshStatus();
|
|
1072
|
+
} catch {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (state.route === "/engines") {
|
|
1076
|
+
// Quota-Zeilen gezielt aktualisieren — keine Inputs/Drawer zerstören
|
|
1077
|
+
for (const st of state.status) {
|
|
1078
|
+
document.querySelectorAll('[data-quota="' + CSS.escape(st.id) + '"]').forEach((el) => {
|
|
1079
|
+
el.innerHTML = quotaHtml(st);
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
} else if (state.route === "/") {
|
|
1083
|
+
const sig = statusSig();
|
|
1084
|
+
if (sig === lastStatusSig) return;
|
|
1085
|
+
lastStatusSig = sig;
|
|
1086
|
+
updateOverview();
|
|
1087
|
+
}
|
|
1088
|
+
}, 30000);
|
|
1089
|
+
|
|
1090
|
+
window.addEventListener("resize", positionNavInd);
|
|
1091
|
+
window.addEventListener("hashchange", () => setRoute(parseHash()));
|
|
1092
|
+
|
|
1093
|
+
/* Favicon-Load-Fehler → farbiger Buchstaben-Avatar (Capture-Phase: "error" bubblt nicht) */
|
|
1094
|
+
document.addEventListener(
|
|
1095
|
+
"error",
|
|
1096
|
+
(ev) => {
|
|
1097
|
+
const img = ev.target;
|
|
1098
|
+
if (!img || !img.matches || !img.matches("img.logo")) return;
|
|
1099
|
+
const hue = Number(img.dataset.hue) || 210;
|
|
1100
|
+
const span = document.createElement("span");
|
|
1101
|
+
span.className = "avatar";
|
|
1102
|
+
span.style.cssText =
|
|
1103
|
+
"background:linear-gradient(135deg,hsl(" + hue + " 62% 52%),hsl(" + ((hue + 28) % 360) + " 68% 40%))";
|
|
1104
|
+
span.textContent = img.dataset.fallback || "?";
|
|
1105
|
+
const wrap = img.closest(".logo-wrap");
|
|
1106
|
+
if (wrap) wrap.replaceWith(span);
|
|
1107
|
+
else img.remove();
|
|
1108
|
+
},
|
|
1109
|
+
true,
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
/* ---------- Boot ---------- */
|
|
1113
|
+
|
|
1114
|
+
initTheme();
|
|
1115
|
+
applyI18n();
|
|
1116
|
+
setRoute(parseHash());
|
|
1117
|
+
load().catch((e) => {
|
|
1118
|
+
if (e.message !== "unauthorized") toast("error", t("toast.error", { msg: e.message }));
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// Keep the numeric input out of the provider-default mode.
|
|
1122
|
+
document.addEventListener("change", ev => {
|
|
1123
|
+
if (ev.target.id !== "result-count-mode") return;
|
|
1124
|
+
const field = $("#result-count");
|
|
1125
|
+
field.disabled = field.hidden = ev.target.value === "provider";
|
|
1126
|
+
});
|