jskelet 0.2.4 → 0.2.5

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.
Files changed (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +8 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1209
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1239
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -670
  40. package/src/client/cache-panel/login.html +74 -74
  41. package/src/client/cache-panel/panel.css +756 -756
  42. package/src/client/cache-panel/panel.html +308 -308
  43. package/src/client/cache-panel/panel.js +915 -915
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -759
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,915 +1,915 @@
1
- /**
2
- * Önbellek panelinin istemci tarafı.
3
- *
4
- * Build hattından geçmez, doğrudan diskten servis edilir: panelin açık olduğu
5
- * bir kurulumda `jskelet build` hiç koşmamış olabilir ve panel yine çalışmalı.
6
- * Bu yüzden burada import edilen bir framework modülü yok.
7
- *
8
- * Yenileme WebSocket değil, kısa aralıklı `fetch` ile: panel nadiren ve kısa
9
- * süre açık kalıyor, kalıcı bir kanal açmanın karşılığı yok.
10
- *
11
- * Bütün görünen metin `i18n.js` üzerinden geçiyor; sunucudan gelen cevaplar da
12
- * metin değil `code` taşıyor, yani dil değişimi tek yerden hallediliyor.
13
- */
14
- import { applyTranslations, languageSelect, t } from "./i18n.js";
15
-
16
- const REFRESH_MS = 3000;
17
-
18
- const $ = (/** @type {string} */ id) => document.getElementById(id);
19
-
20
- /** @type {"html" | "data"} */
21
- let tab = "html";
22
- let query = "";
23
- /** @type {any} */
24
- let latest = null;
25
- /** @type {number | null} */
26
- let timer = null;
27
- let busy = false;
28
-
29
- /* ------------------------------------------------------------ biçimlendirme */
30
-
31
- /**
32
- * @param {number} bytes
33
- * @returns {string}
34
- */
35
- function formatBytes(bytes) {
36
- if (!bytes) return "0 B";
37
- const units = ["B", "KB", "MB", "GB"];
38
- const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
39
- const value = bytes / 1024 ** power;
40
- return `${value >= 100 || power === 0 ? Math.round(value) : value.toFixed(1)} ${units[power]}`;
41
- }
42
-
43
- /**
44
- * @param {number} seconds
45
- * @returns {string}
46
- */
47
- function formatDuration(seconds) {
48
- const total = Math.max(0, Math.round(seconds));
49
- const [s, m, h, d] = [t("unit.second"), t("unit.minute"), t("unit.hour"), t("unit.day")];
50
-
51
- if (total < 60) return `${total}${s}`;
52
- if (total < 3600) return `${Math.floor(total / 60)}${m} ${total % 60}${s}`;
53
- if (total < 86_400) {
54
- return `${Math.floor(total / 3600)}${h} ${Math.floor((total % 3600) / 60)}${m}`;
55
- }
56
- return `${Math.floor(total / 86_400)}${d} ${Math.floor((total % 86_400) / 3600)}${h}`;
57
- }
58
-
59
- /**
60
- * @param {string} message
61
- * @param {boolean} [ok]
62
- */
63
- function toast(message, ok = true) {
64
- const node = document.createElement("div");
65
- node.className = ok ? "toast" : "toast bad";
66
- node.textContent = message;
67
- $("toast").append(node);
68
-
69
- setTimeout(() => node.remove(), 5000);
70
- }
71
-
72
- /* -------------------------------------------------------------------- veri */
73
-
74
- /**
75
- * Sunucu yetkisiz isteklere 404 dönüyor (panelin varlığını doğrulamamak
76
- * için). Oturum düştüyse sayfa yeniden yüklenir ve giriş formuna dönülür;
77
- * arka planda dönmeye devam eden bir yoklama, IP'yi kendi kendine
78
- * yasaklatırdı.
79
- *
80
- * @param {Response} response
81
- * @returns {boolean} İstek işlenebilir mi.
82
- */
83
- function alive(response) {
84
- if (response.status !== 404) return true;
85
-
86
- stopPolling();
87
- location.reload();
88
- return false;
89
- }
90
-
91
- async function load() {
92
- if (busy) return;
93
- busy = true;
94
-
95
- try {
96
- const response = await fetch(`data?q=${encodeURIComponent(query)}`, {
97
- headers: { Accept: "application/json" },
98
- });
99
- if (!alive(response)) return;
100
- if (!response.ok) return;
101
-
102
- latest = await response.json();
103
- render();
104
- } catch {
105
- // Sunucu yeniden başlıyor olabilir; bir sonraki tur dener.
106
- } finally {
107
- busy = false;
108
- }
109
- }
110
-
111
- /**
112
- * @param {Record<string, unknown>} body
113
- * @returns {Promise<void>}
114
- */
115
- async function act(body) {
116
- const response = await fetch("action", {
117
- method: "POST",
118
- headers: {
119
- "Content-Type": "application/json",
120
- // Panelin CSRF freni: çapraz siteden gönderilemeyen bir başlık.
121
- "X-JSkelet-Cache-Panel": "1",
122
- },
123
- body: JSON.stringify(body),
124
- });
125
-
126
- if (!alive(response)) return;
127
-
128
- const result = await response.json().catch(() => null);
129
- toast(describe(result), result?.ok !== false);
130
- await load();
131
-
132
- // Cloudflare ayarları değişmiş olabilir; genel bakış önbelleği atlanır.
133
- if (String(body.type ?? "").startsWith("cf:")) await loadCloudflare(true);
134
- }
135
-
136
- /**
137
- * Sunucu cevabını metne çevirir.
138
- *
139
- * `/action` dil taşımıyor: `{ ok, code, params }` dönüyor ve cümle burada
140
- * kuruluyor. Redis sayımı gibi parçalı cevaplar `parts` ile geliyor, çünkü
141
- * "veritabanında kaç anahtar var" bilgisi her zaman okunamıyor ve eksik parçayı
142
- * cümleden düşürmek tek düzgün yol.
143
- *
144
- * @param {any} result
145
- * @returns {string}
146
- */
147
- function describe(result) {
148
- if (!result) return t("toast.failed");
149
-
150
- if (Array.isArray(result.parts)) {
151
- const parts = result.parts
152
- .map((/** @type {any} */ part) => t(`msg.${part.code}`, part.params))
153
- .filter(Boolean);
154
- if (parts.length) return parts.join(" · ");
155
- }
156
-
157
- if (result.code) return t(`msg.${result.code}`, result.params);
158
- return t(result.ok ? "toast.done" : "toast.failed");
159
- }
160
-
161
- /* ------------------------------------------------------------------- çizim */
162
-
163
- function render() {
164
- if (!latest) return;
165
-
166
- const { process: proc, release, host, html, data, redis, prewarm } = latest;
167
-
168
- $("version").textContent = `v${release.version}`;
169
- $("foot-release").textContent = [
170
- `v${release.version}`,
171
- release.license,
172
- release.node ? `Node ${release.node}` : null,
173
- ]
174
- .filter(Boolean)
175
- .join(" · ");
176
- if (release.homepage) $("foot-repo").href = release.homepage;
177
-
178
- $("env").textContent = proc.env;
179
- $("pid").textContent = `pid ${proc.pid}`;
180
- $("uptime").textContent = t("chip.up", { value: formatDuration(proc.uptime) });
181
- $("memory").textContent = `rss ${formatBytes(proc.memory.rss)}`;
182
-
183
- $("html-size").textContent = html.size.toLocaleString();
184
- $("html-sub").textContent = t("card.limit", { count: html.maxEntries.toLocaleString() });
185
- $("html-bytes").textContent = formatBytes(html.bytes);
186
- $("html-stale").textContent = t("card.stale", { count: html.stale });
187
-
188
- $("data-size").textContent = data.size.toLocaleString();
189
- $("data-sub").textContent = t("card.staleAndLimit", {
190
- stale: data.stale,
191
- limit: data.maxEntries.toLocaleString(),
192
- });
193
-
194
- const redisState = !redis.enabled
195
- ? "off"
196
- : redis.bypassed
197
- ? "bypassed"
198
- : redis.connected
199
- ? "connected"
200
- : "disconnected";
201
-
202
- $("redis-state").textContent = t(`state.${redisState}`);
203
- $("redis-sub").textContent = redis.enabled
204
- ? t("redis.sub", { address: redis.address, errors: redis.errors })
205
- : t("redis.disabled");
206
-
207
- renderRedis(redis, redisState);
208
- renderHost(host, proc, redis);
209
-
210
- const done = prewarm.done ?? 0;
211
- const total = prewarm.total ?? 0;
212
- $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : t("state.idle");
213
- $("prewarm-sub").textContent = prewarm.active
214
- ? t("prewarm.inProgress")
215
- : total
216
- ? t("prewarm.lastRound", { done, total })
217
- : t("prewarm.never");
218
-
219
- renderTable();
220
- }
221
-
222
- /* ------------------------------------------------------------- cloudflare */
223
-
224
- /** @type {any} */
225
- let cloudflare = null;
226
-
227
- /**
228
- * Cloudflare genel bakışı ayrı bir uçtan gelir: zone bilgisi ve ayarlar ağa
229
- * çıkıyor, döküm turu ise saniyeler mertebesinde yenileniyor. Bu yüzden
230
- * yalnızca açılışta ve bir işlemden sonra istenir.
231
- *
232
- * @param {boolean} [force]
233
- */
234
- async function loadCloudflare(force = false) {
235
- const response = await fetch(`cloudflare${force ? "?force=1" : ""}`);
236
- if (!alive(response)) return;
237
-
238
- cloudflare = await response.json().catch(() => null);
239
- renderCloudflare();
240
- }
241
-
242
- function renderCloudflare() {
243
- if (!cloudflare) return;
244
-
245
- const ok = cloudflare.ok === true;
246
- const status = cloudflare.status ?? {};
247
- const settings = cloudflare.settings ?? {};
248
- const zone = cloudflare.zone ?? null;
249
-
250
- $("cf-heading").textContent = ok
251
- ? [zone?.name, zone?.plan].filter(Boolean).join(" · ") || t("state.connected")
252
- : status.configured
253
- ? t("cf.error", { error: cloudflare.error })
254
- : t("cf.notConnected");
255
-
256
- $("cf-tools").hidden = !ok;
257
- $("cf-suggest").hidden = ok || status.configured;
258
- $("cf-refresh").hidden = !status.configured;
259
-
260
- if (!ok) {
261
- $("cf-kv").replaceChildren();
262
- return;
263
- }
264
-
265
- const development = settings.developmentMode === "on";
266
- $("cf-dev").checked = development;
267
-
268
- /** @type {[string, string, string?][]} */
269
- const rows = [
270
- [t("cf.zone"), `${zone?.name ?? "—"} (${status.zoneId ?? "?"})`],
271
- [t("cf.plan"), zone?.plan ?? "—"],
272
- [
273
- t("cf.token"),
274
- status.tokenSource === "env" ? t("cf.token.env") : t("cf.token.config"),
275
- "on",
276
- ],
277
- [
278
- t("cf.devModeLabel"),
279
- development
280
- ? t("cf.devMode.on", { time: formatDuration(settings.developmentModeRemaining) })
281
- : t("state.off"),
282
- development ? "bad" : "on",
283
- ],
284
- [t("cf.cacheLevel"), String(settings.cacheLevel ?? "—")],
285
- [t("cf.browserTtl"), ttl(settings.browserCacheTtl)],
286
- [t("cf.edgeTtl"), ttl(settings.edgeCacheTtl)],
287
- [t("cf.sortQuery"), String(settings.sortQueryString ?? "—")],
288
- [t("cf.alwaysOnline"), String(settings.alwaysOnline ?? "—")],
289
- ...feature(t("cf.tiered"), settings.tieredCaching),
290
- ...feature(t("cf.regionalTiered"), settings.regionalTieredCache),
291
- ...feature(t("cf.reserve"), settings.cacheReserve),
292
- ];
293
-
294
- $("cf-kv").replaceChildren(...rows.map(kvRow));
295
- }
296
-
297
- /**
298
- * Plan dışı kalan bir özellik `null` döner. "off" yazmak yanıltıcı olurdu:
299
- * kapatılmış değil, o planda hiç yok.
300
- *
301
- * @param {string} label
302
- * @param {string | null} value
303
- * @returns {[string, string, string?][]}
304
- */
305
- function feature(label, value) {
306
- if (value === null || value === undefined) {
307
- return [[label, t("cf.unavailablePlan"), "off"]];
308
- }
309
- const on = value === "on";
310
- return [[label, on ? t("state.on") : t("state.off"), on ? "on" : "off"]];
311
- }
312
-
313
- /**
314
- * @param {unknown} seconds
315
- * @returns {string}
316
- */
317
- function ttl(seconds) {
318
- const value = Number(seconds);
319
- if (!Number.isFinite(value)) return "—";
320
- // Cloudflare 0'ı "Respect Existing Headers" olarak kullanıyor.
321
- if (value === 0) return t("cf.respectOrigin");
322
- return formatDuration(value);
323
- }
324
-
325
- /**
326
- * @param {[string, string, string?]} row
327
- * @returns {HTMLDivElement}
328
- */
329
- function kvRow([label, value, tone]) {
330
- const wrap = document.createElement("div");
331
- const dt = document.createElement("dt");
332
- dt.textContent = label;
333
- const dd = document.createElement("dd");
334
- if (tone) dd.className = tone;
335
- dd.textContent = value;
336
- wrap.append(dt, dd);
337
- return wrap;
338
- }
339
-
340
- /**
341
- * Analitik dökümü. İki soru tek uçtan cevaplanıyor: yol verilirse kolo
342
- * kırılımı, verilmezse zone genelinde cache durumu dağılımı.
343
- */
344
- async function loadCloudflareAnalytics() {
345
- const path = $("cf-path").value.trim();
346
- const target = $("cf-report");
347
-
348
- target.hidden = false;
349
- target.textContent = t("cf.querying");
350
-
351
- const response = await fetch("cloudflare/analytics", {
352
- method: "POST",
353
- headers: {
354
- "Content-Type": "application/json",
355
- "X-JSkelet-Cache-Panel": "1",
356
- },
357
- body: JSON.stringify({ path }),
358
- });
359
-
360
- if (!alive(response)) return;
361
-
362
- const result = await response.json().catch(() => null);
363
-
364
- if (!result?.ok) {
365
- target.textContent = t("msg.cf.failed", { error: result?.error ?? t("cf.queryFailed") });
366
- return;
367
- }
368
-
369
- // Son dökümü dil değişiminde yeniden kurmak için saklanır: tablo ağdan
370
- // geliyor ve dil değişti diye Cloudflare'e yeniden gitmek gereksiz.
371
- lastReport = result;
372
- renderReport();
373
- }
374
-
375
- /** @type {any} */
376
- let lastReport = null;
377
-
378
- function renderReport() {
379
- if (!lastReport) return;
380
- const result = lastReport;
381
-
382
- $("cf-report").replaceChildren(
383
- result.colos ? edgeTable(result) : statusTable(result),
384
- note(
385
- result.colos
386
- ? t("cf.edgeNote", {
387
- colos: result.colos.length,
388
- path: result.path,
389
- hours: result.hours,
390
- hits: result.hits,
391
- misses: result.misses,
392
- })
393
- : t("cf.zoneNote", { hours: result.hours }),
394
- ),
395
- );
396
- }
397
-
398
- /**
399
- * @param {any} result
400
- * @returns {HTMLTableElement}
401
- */
402
- function edgeTable(result) {
403
- return buildTable(
404
- [t("cf.colo"), t("cf.fromCache"), t("cf.toOrigin")],
405
- result.colos.map((/** @type {any} */ row) => [
406
- row.colo,
407
- String(row.hits),
408
- String(row.misses),
409
- ]),
410
- );
411
- }
412
-
413
- /**
414
- * @param {any} result
415
- * @returns {HTMLTableElement}
416
- */
417
- function statusTable(result) {
418
- const total = result.rows.reduce((sum, /** @type {any} */ row) => sum + row.requests, 0) || 1;
419
-
420
- return buildTable(
421
- [t("cf.cacheStatus"), t("cf.requests"), t("cf.share"), t("cf.edgeBytes")],
422
- result.rows.map((/** @type {any} */ row) => [
423
- row.status,
424
- row.requests.toLocaleString(),
425
- `${Math.round((row.requests / total) * 100)}%`,
426
- formatBytes(row.bytes),
427
- ]),
428
- );
429
- }
430
-
431
- /**
432
- * @param {string[]} headers
433
- * @param {string[][]} rows
434
- * @returns {HTMLTableElement}
435
- */
436
- function buildTable(headers, rows) {
437
- const table = document.createElement("table");
438
- const thead = document.createElement("thead");
439
- const headRow = document.createElement("tr");
440
-
441
- headers.forEach((label, index) => {
442
- const th = document.createElement("th");
443
- th.textContent = label;
444
- if (index > 0) th.className = "num";
445
- headRow.append(th);
446
- });
447
-
448
- thead.append(headRow);
449
-
450
- const tbody = document.createElement("tbody");
451
- for (const row of rows) {
452
- const tr = document.createElement("tr");
453
- row.forEach((value, index) => tr.append(cell(value, index > 0 ? "num" : undefined)));
454
- tbody.append(tr);
455
- }
456
-
457
- table.append(thead, tbody);
458
- return table;
459
- }
460
-
461
- /**
462
- * @param {string} text
463
- * @returns {HTMLParagraphElement}
464
- */
465
- function note(text) {
466
- const paragraph = document.createElement("p");
467
- paragraph.className = "note m0";
468
- paragraph.textContent = text;
469
- return paragraph;
470
- }
471
-
472
- /**
473
- * Paylaşımlı kademenin künyesi.
474
- *
475
- * "Bağlı mı" tek başına yetmiyor: yanlış `namespace` ya da beklenmeyen bir
476
- * `buildId` ile bağlı bir Redis de hiçbir şey paylaşmıyor gibi görünür. Bu
477
- * yüzden anahtarın **nerede** durduğu ve hangi türlerin paylaşıldığı da
478
- * yazılır.
479
- *
480
- * @param {any} redis
481
- * @param {string} state
482
- */
483
- function renderRedis(redis, state) {
484
- $("redis-heading").textContent = redis.enabled ? t(`state.${state}`) : t("state.notConfigured");
485
- $("redis-actions").hidden = !redis.enabled;
486
- $("redis-suggest").hidden = redis.enabled;
487
-
488
- /** @type {[string, string, string?][]} */
489
- const rows = redis.enabled
490
- ? [
491
- [
492
- t("redis.connection"),
493
- t(`state.${state}`),
494
- redis.connected && !redis.bypassed ? "on" : "bad",
495
- ],
496
- [t("redis.address"), `${redis.address}${redis.secure ? " (TLS)" : ""}`],
497
- [t("redis.database"), redis.db ?? t("state.default")],
498
- [t("redis.keyPrefix"), `${redis.keyPrefix}:${redis.namespace}`],
499
- [t("redis.buildId"), redis.buildId || "dev"],
500
- [
501
- t("redis.sharesHtml"),
502
- redis.html ? t("state.yes") : t("state.no"),
503
- redis.html ? "on" : "off",
504
- ],
505
- [
506
- t("redis.sharesData"),
507
- redis.data ? t("state.yes") : t("state.no"),
508
- redis.data ? "on" : "off",
509
- ],
510
- [
511
- t("redis.compressed"),
512
- redis.storeEncoded ? t("redis.compressed.shared") : t("redis.compressed.local"),
513
- redis.storeEncoded ? "on" : "off",
514
- ],
515
- [
516
- t("redis.broadcast"),
517
- redis.events
518
- ? redis.subscribed
519
- ? t("redis.broadcast.subscribed")
520
- : t("redis.broadcast.publish")
521
- : t("state.off"),
522
- redis.events && redis.subscribed ? "on" : "off",
523
- ],
524
- [t("redis.timeout"), `${redis.commandTimeoutMs} ${t("unit.ms")}`],
525
- [t("redis.errors"), String(redis.errors), redis.errors ? "bad" : "on"],
526
- ]
527
- : [
528
- [t("redis.tier"), t("redis.tier.inProcess")],
529
- [t("redis.replicas"), t("state.none")],
530
- [t("redis.broadcast"), t("redis.broadcast.local"), "off"],
531
- ];
532
-
533
- $("redis-kv").replaceChildren(...rows.map(kvRow));
534
- }
535
-
536
- /**
537
- * Makinenin RAM ve disk durumu. Paylaşımlı kademe kapalıyken önbelleğin
538
- * tamamı bu sürecin belleğinde yaşıyor; `maxEntries` ile RAM arasındaki
539
- * ilişkiyi görmeden ayar yapmak körlemesine oluyor.
540
- *
541
- * @param {any} host
542
- * @param {any} proc
543
- * @param {any} redis
544
- */
545
- function renderHost(host, proc, redis) {
546
- $("host-heading").textContent = [
547
- host.platform,
548
- `${host.cpus} cpu`,
549
- host.load ? `load ${host.load.toFixed(2)}` : null,
550
- ]
551
- .filter(Boolean)
552
- .join(" · ");
553
-
554
- const ramUsed = host.memory.used / host.memory.total;
555
- $("ram-text").textContent =
556
- `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}`;
557
- meter("ram-bar", ramUsed);
558
- $("ram-note").textContent = redis.connected
559
- ? t("host.ramShared", { rss: formatBytes(proc.memory.rss) })
560
- : t("host.ramOnly", {
561
- rss: formatBytes(proc.memory.rss),
562
- html: formatBytes(latest.html.bytes),
563
- });
564
-
565
- if (!host.disk) {
566
- $("disk-text").textContent = t("state.unavailable");
567
- meter("disk-bar", 0);
568
- $("disk-note").textContent = t("host.noStats");
569
- return;
570
- }
571
-
572
- const diskUsed = (host.disk.total - host.disk.free) / host.disk.total;
573
- $("disk-text").textContent =
574
- `${formatBytes(host.disk.total - host.disk.free)} / ${formatBytes(host.disk.total)}`;
575
- meter("disk-bar", diskUsed);
576
- // Önbellek diske yazılmıyor; disk build çıktısı ve log için önemli.
577
- $("disk-note").textContent = t("host.diskNote", {
578
- free: formatBytes(host.disk.free),
579
- path: host.disk.path,
580
- });
581
- }
582
-
583
- /**
584
- * @param {string} id
585
- * @param {number} ratio 0–1
586
- */
587
- function meter(id, ratio) {
588
- const bar = $(id);
589
- const percent = Math.max(0, Math.min(100, ratio * 100));
590
- bar.style.width = `${percent}%`;
591
- bar.className = percent >= 90 ? "bad" : percent >= 75 ? "warn" : "";
592
- }
593
-
594
- function renderTable() {
595
- const source = tab === "html" ? latest.html : latest.data;
596
- const thead = $("thead");
597
- const tbody = $("tbody");
598
-
599
- $("listed").textContent =
600
- source.matched > source.entries.length
601
- ? t("entries.shownPartial", { shown: source.entries.length, total: source.matched })
602
- : t("entries.shown", { total: source.matched });
603
-
604
- const columns =
605
- tab === "html"
606
- ? [
607
- ["entries.key"],
608
- ["entries.state"],
609
- ["entries.size", "num"],
610
- ["entries.status", "num"],
611
- ["entries.expires", "num"],
612
- ["entries.deps", "num"],
613
- ["entries.encodings"],
614
- [null],
615
- ]
616
- : [["entries.key"], ["entries.state"], ["entries.expires", "num"], [null]];
617
-
618
- const headRow = document.createElement("tr");
619
- for (const [key, className] of columns) {
620
- const th = document.createElement("th");
621
- if (className) th.className = className;
622
- if (key) th.textContent = t(key);
623
- headRow.append(th);
624
- }
625
- thead.replaceChildren(headRow);
626
-
627
- tbody.replaceChildren(
628
- ...source.entries.map((/** @type {any} */ entry) => row(entry)),
629
- );
630
-
631
- $("empty").hidden = source.entries.length > 0;
632
- }
633
-
634
- /**
635
- * @param {any} entry
636
- * @returns {HTMLTableRowElement}
637
- */
638
- function row(entry) {
639
- const tr = document.createElement("tr");
640
-
641
- const key = document.createElement("td");
642
- key.className = "key mono";
643
-
644
- if (tab === "html") {
645
- // HTML anahtarı gezilebilir bir yol: yeni sekmede açmak, bir sayfanın
646
- // neden bayat olduğunu anlamanın en kısa yolu. `noreferrer` panelin
647
- // yolunu `Referer` başlığıyla sızdırmamak için.
648
- const link = document.createElement("a");
649
- link.href = entry.url;
650
- link.target = "_blank";
651
- link.rel = "noreferrer noopener";
652
- link.textContent = entry.url;
653
- key.append(link);
654
- } else {
655
- // Veri anahtarı bir URL değil; tıklamanın işe yarar karşılığı onu
656
- // `clearDataCache()` çağrısına yapıştırabilmek.
657
- const copy = document.createElement("button");
658
- copy.className = "as-text";
659
- copy.type = "button";
660
- copy.title = t("entries.copyKey");
661
- copy.textContent = entry.key;
662
- copy.addEventListener("click", () => void copyKey(entry.key));
663
- key.append(copy);
664
- }
665
-
666
- tr.append(key);
667
-
668
- const state = document.createElement("td");
669
- const tag = document.createElement("span");
670
- tag.className = entry.stale ? "tag stale" : "tag fresh";
671
- tag.textContent = entry.stale ? t("entries.stale") : t("entries.fresh");
672
- state.append(tag);
673
- tr.append(state);
674
-
675
- if (tab === "html") {
676
- tr.append(
677
- cell(formatBytes(entry.bytes), "num"),
678
- cell(String(entry.status), "num"),
679
- cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"),
680
- cell(String(entry.deps), "num"),
681
- cell(entry.encodings.length ? entry.encodings.join(", ") : "—"),
682
- );
683
- } else {
684
- tr.append(cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"));
685
- }
686
-
687
- const actions = document.createElement("td");
688
- actions.className = "actions";
689
-
690
- // Tek satırı hem origin'den hem edge'den düşürmek en sık istenen çift
691
- // işlem; Cloudflare bağlı değilse düğme hiç çıkmaz.
692
- if (tab === "html" && cloudflare?.ok) {
693
- const purge = document.createElement("button");
694
- purge.className = "tiny";
695
- purge.textContent = t("cf.rowPurge");
696
- purge.title = t("cf.rowPurgeTitle");
697
- purge.addEventListener("click", () => {
698
- void act({ type: "cf:purge-urls", paths: [entry.url] });
699
- });
700
- actions.append(purge);
701
- }
702
-
703
- const drop = document.createElement("button");
704
- drop.className = "tiny danger";
705
- drop.textContent = t("entries.drop");
706
- drop.addEventListener("click", () => {
707
- void act({ type: tab === "html" ? "html:drop" : "data:drop", key: entry.key });
708
- });
709
-
710
- actions.append(drop);
711
- tr.append(actions);
712
-
713
- return tr;
714
- }
715
-
716
- /**
717
- * Panel `http://localhost` dışında da açılabiliyor ve tarayıcı güvensiz
718
- * kaynaklarda pano API'sini vermiyor; o durumda anahtar seçili bırakılır.
719
- *
720
- * @param {string} key
721
- */
722
- async function copyKey(key) {
723
- try {
724
- await navigator.clipboard.writeText(key);
725
- toast(t("toast.keyCopied"));
726
- } catch {
727
- $("prefix").value = key;
728
- $("prefix").focus();
729
- toast(t("toast.clipboard"), false);
730
- }
731
- }
732
-
733
- /**
734
- * @param {string} text
735
- * @param {string} [className]
736
- * @returns {HTMLTableCellElement}
737
- */
738
- function cell(text, className) {
739
- const td = document.createElement("td");
740
- if (className) td.className = className;
741
- td.textContent = text;
742
- return td;
743
- }
744
-
745
- /* ------------------------------------------------------------------ olaylar */
746
-
747
- function startPolling() {
748
- stopPolling();
749
- timer = setInterval(load, REFRESH_MS);
750
- }
751
-
752
- function stopPolling() {
753
- if (timer !== null) clearInterval(timer);
754
- timer = null;
755
- }
756
-
757
- $("auto").addEventListener("change", (event) => {
758
- if (/** @type {HTMLInputElement} */ (event.target).checked) startPolling();
759
- else stopPolling();
760
- });
761
-
762
- $("refresh").addEventListener("click", () => void load());
763
-
764
- $("logout").addEventListener("click", async () => {
765
- stopPolling();
766
- await fetch("logout", { method: "POST" });
767
- location.reload();
768
- });
769
-
770
- // Arama her tuşta sunucuya gitmez: on binlerce anahtarlı bir önbellekte
771
- // filtreleme sunucuda yapılıyor.
772
- let searchTimer = 0;
773
- $("search").addEventListener("input", (event) => {
774
- query = /** @type {HTMLInputElement} */ (event.target).value;
775
- clearTimeout(searchTimer);
776
- searchTimer = setTimeout(load, 200);
777
- });
778
-
779
- for (const name of /** @type {const} */ (["html", "data"])) {
780
- $(`tab-${name}`).addEventListener("click", () => {
781
- tab = name;
782
- $("tab-html").setAttribute("aria-selected", String(name === "html"));
783
- $("tab-data").setAttribute("aria-selected", String(name === "data"));
784
- renderTable();
785
- });
786
- }
787
-
788
- $("invalidate").addEventListener("click", () => {
789
- const target = $("target").value.trim();
790
- if (!target) {
791
- toast(t("toast.needTarget"), false);
792
- return;
793
- }
794
-
795
- void act({ type: "html:invalidate", target, hard: $("hard").checked });
796
- });
797
-
798
- $("clear-html").addEventListener("click", () => {
799
- if (!confirm(t("confirm.clearHtml"))) return;
800
- void act({ type: "html:clear" });
801
- });
802
-
803
- $("clear-data").addEventListener("click", () => {
804
- const prefix = $("prefix").value.trim();
805
- if (!prefix && !confirm(t("confirm.clearData"))) {
806
- return;
807
- }
808
-
809
- void act({ type: "data:clear", prefix });
810
- });
811
-
812
- $("prewarm").addEventListener("click", () => void act({ type: "prewarm" }));
813
-
814
- // Sayım ayrı bir düğme: her döküm turunda tüm keyspace'i taramak Redis'i
815
- // teşhis uğruna yormak olurdu.
816
- $("redis-inspect").addEventListener("click", () => void act({ type: "redis:inspect" }));
817
-
818
- $("redis-html").addEventListener("click", () =>
819
- void act({ type: "redis:drop", kind: "html" }),
820
- );
821
-
822
- $("redis-data").addEventListener("click", () =>
823
- void act({ type: "redis:drop", kind: "data" }),
824
- );
825
-
826
- /* ------------------------------------------------- cloudflare kontrolleri */
827
-
828
- $("cf-refresh").addEventListener("click", () => void loadCloudflare(true));
829
-
830
- $("cf-purge-all").addEventListener("click", () => {
831
- if (!confirm(t("confirm.purgeAll"))) return;
832
- void act({ type: "cf:purge-everything" });
833
- });
834
-
835
- // Bu kurulumda sıcak olan yollar zaten biliniyor; "her şeyi düşür" yerine
836
- // yalnızca gerçekten servis edilen sayfaları düşürmek edge'i boşa soğutmaz.
837
- $("cf-purge-cached").addEventListener("click", () => {
838
- const paths = (latest?.html.entries ?? []).map((/** @type {any} */ entry) => entry.url);
839
- if (!paths.length) {
840
- toast(t("toast.noCachedPages"), false);
841
- return;
842
- }
843
-
844
- if (!confirm(t("confirm.purgeUrls", { count: paths.length }))) return;
845
- void act({ type: "cf:purge-urls", paths });
846
- });
847
-
848
- $("cf-purge-keys").addEventListener("click", () => {
849
- const values = $("cf-values").value.trim();
850
- if (!values) {
851
- toast(t("toast.needValue"), false);
852
- return;
853
- }
854
-
855
- void act({ type: "cf:purge-keys", kind: $("cf-kind").value, values });
856
- });
857
-
858
- $("cf-dev").addEventListener("change", (event) => {
859
- const on = /** @type {HTMLInputElement} */ (event.target).checked;
860
- void act({ type: "cf:setting", id: "development_mode", value: on ? "on" : "off" });
861
- });
862
-
863
- $("cf-tiered").addEventListener("click", () => {
864
- const current = cloudflare?.settings?.tieredCaching;
865
- void act({ type: "cf:feature", feature: "tiered_caching", value: flip(current) });
866
- });
867
-
868
- $("cf-reserve").addEventListener("click", () => {
869
- const current = cloudflare?.settings?.cacheReserve;
870
- void act({ type: "cf:feature", feature: "cache_reserve", value: flip(current) });
871
- });
872
-
873
- $("cf-reserve-clear").addEventListener("click", () => {
874
- if (!confirm(t("confirm.clearReserve"))) return;
875
- void act({ type: "cf:clear-reserve" });
876
- });
877
-
878
- $("cf-analytics").addEventListener("click", () => void loadCloudflareAnalytics());
879
-
880
- /**
881
- * @param {string | null | undefined} value
882
- * @returns {"on" | "off"}
883
- */
884
- function flip(value) {
885
- return value === "on" ? "off" : "on";
886
- }
887
-
888
- // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
889
- // dakikalarca boşa istek atmasın.
890
- document.addEventListener("visibilitychange", () => {
891
- if (document.hidden) stopPolling();
892
- else if ($("auto").checked) {
893
- void load();
894
- startPolling();
895
- }
896
- });
897
-
898
- /* -------------------------------------------------------------------- dil */
899
-
900
- // Dil değişimi hiçbir isteğe yol açmaz: statik metinler sözlükten yeniden
901
- // yazılır, dinamik olanlar elde duran son dökümle tekrar çizilir.
902
- $("language").append(
903
- languageSelect(() => {
904
- applyTranslations();
905
- if (latest) render();
906
- renderCloudflare();
907
- renderReport();
908
- }),
909
- );
910
-
911
- applyTranslations();
912
-
913
- void load();
914
- void loadCloudflare();
915
- startPolling();
1
+ /**
2
+ * Önbellek panelinin istemci tarafı.
3
+ *
4
+ * Build hattından geçmez, doğrudan diskten servis edilir: panelin açık olduğu
5
+ * bir kurulumda `jskelet build` hiç koşmamış olabilir ve panel yine çalışmalı.
6
+ * Bu yüzden burada import edilen bir framework modülü yok.
7
+ *
8
+ * Yenileme WebSocket değil, kısa aralıklı `fetch` ile: panel nadiren ve kısa
9
+ * süre açık kalıyor, kalıcı bir kanal açmanın karşılığı yok.
10
+ *
11
+ * Bütün görünen metin `i18n.js` üzerinden geçiyor; sunucudan gelen cevaplar da
12
+ * metin değil `code` taşıyor, yani dil değişimi tek yerden hallediliyor.
13
+ */
14
+ import { applyTranslations, languageSelect, t } from "./i18n.js";
15
+
16
+ const REFRESH_MS = 3000;
17
+
18
+ const $ = (/** @type {string} */ id) => document.getElementById(id);
19
+
20
+ /** @type {"html" | "data"} */
21
+ let tab = "html";
22
+ let query = "";
23
+ /** @type {any} */
24
+ let latest = null;
25
+ /** @type {number | null} */
26
+ let timer = null;
27
+ let busy = false;
28
+
29
+ /* ------------------------------------------------------------ biçimlendirme */
30
+
31
+ /**
32
+ * @param {number} bytes
33
+ * @returns {string}
34
+ */
35
+ function formatBytes(bytes) {
36
+ if (!bytes) return "0 B";
37
+ const units = ["B", "KB", "MB", "GB"];
38
+ const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
39
+ const value = bytes / 1024 ** power;
40
+ return `${value >= 100 || power === 0 ? Math.round(value) : value.toFixed(1)} ${units[power]}`;
41
+ }
42
+
43
+ /**
44
+ * @param {number} seconds
45
+ * @returns {string}
46
+ */
47
+ function formatDuration(seconds) {
48
+ const total = Math.max(0, Math.round(seconds));
49
+ const [s, m, h, d] = [t("unit.second"), t("unit.minute"), t("unit.hour"), t("unit.day")];
50
+
51
+ if (total < 60) return `${total}${s}`;
52
+ if (total < 3600) return `${Math.floor(total / 60)}${m} ${total % 60}${s}`;
53
+ if (total < 86_400) {
54
+ return `${Math.floor(total / 3600)}${h} ${Math.floor((total % 3600) / 60)}${m}`;
55
+ }
56
+ return `${Math.floor(total / 86_400)}${d} ${Math.floor((total % 86_400) / 3600)}${h}`;
57
+ }
58
+
59
+ /**
60
+ * @param {string} message
61
+ * @param {boolean} [ok]
62
+ */
63
+ function toast(message, ok = true) {
64
+ const node = document.createElement("div");
65
+ node.className = ok ? "toast" : "toast bad";
66
+ node.textContent = message;
67
+ $("toast").append(node);
68
+
69
+ setTimeout(() => node.remove(), 5000);
70
+ }
71
+
72
+ /* -------------------------------------------------------------------- veri */
73
+
74
+ /**
75
+ * Sunucu yetkisiz isteklere 404 dönüyor (panelin varlığını doğrulamamak
76
+ * için). Oturum düştüyse sayfa yeniden yüklenir ve giriş formuna dönülür;
77
+ * arka planda dönmeye devam eden bir yoklama, IP'yi kendi kendine
78
+ * yasaklatırdı.
79
+ *
80
+ * @param {Response} response
81
+ * @returns {boolean} İstek işlenebilir mi.
82
+ */
83
+ function alive(response) {
84
+ if (response.status !== 404) return true;
85
+
86
+ stopPolling();
87
+ location.reload();
88
+ return false;
89
+ }
90
+
91
+ async function load() {
92
+ if (busy) return;
93
+ busy = true;
94
+
95
+ try {
96
+ const response = await fetch(`data?q=${encodeURIComponent(query)}`, {
97
+ headers: { Accept: "application/json" },
98
+ });
99
+ if (!alive(response)) return;
100
+ if (!response.ok) return;
101
+
102
+ latest = await response.json();
103
+ render();
104
+ } catch {
105
+ // Sunucu yeniden başlıyor olabilir; bir sonraki tur dener.
106
+ } finally {
107
+ busy = false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * @param {Record<string, unknown>} body
113
+ * @returns {Promise<void>}
114
+ */
115
+ async function act(body) {
116
+ const response = await fetch("action", {
117
+ method: "POST",
118
+ headers: {
119
+ "Content-Type": "application/json",
120
+ // Panelin CSRF freni: çapraz siteden gönderilemeyen bir başlık.
121
+ "X-JSkelet-Cache-Panel": "1",
122
+ },
123
+ body: JSON.stringify(body),
124
+ });
125
+
126
+ if (!alive(response)) return;
127
+
128
+ const result = await response.json().catch(() => null);
129
+ toast(describe(result), result?.ok !== false);
130
+ await load();
131
+
132
+ // Cloudflare ayarları değişmiş olabilir; genel bakış önbelleği atlanır.
133
+ if (String(body.type ?? "").startsWith("cf:")) await loadCloudflare(true);
134
+ }
135
+
136
+ /**
137
+ * Sunucu cevabını metne çevirir.
138
+ *
139
+ * `/action` dil taşımıyor: `{ ok, code, params }` dönüyor ve cümle burada
140
+ * kuruluyor. Redis sayımı gibi parçalı cevaplar `parts` ile geliyor, çünkü
141
+ * "veritabanında kaç anahtar var" bilgisi her zaman okunamıyor ve eksik parçayı
142
+ * cümleden düşürmek tek düzgün yol.
143
+ *
144
+ * @param {any} result
145
+ * @returns {string}
146
+ */
147
+ function describe(result) {
148
+ if (!result) return t("toast.failed");
149
+
150
+ if (Array.isArray(result.parts)) {
151
+ const parts = result.parts
152
+ .map((/** @type {any} */ part) => t(`msg.${part.code}`, part.params))
153
+ .filter(Boolean);
154
+ if (parts.length) return parts.join(" · ");
155
+ }
156
+
157
+ if (result.code) return t(`msg.${result.code}`, result.params);
158
+ return t(result.ok ? "toast.done" : "toast.failed");
159
+ }
160
+
161
+ /* ------------------------------------------------------------------- çizim */
162
+
163
+ function render() {
164
+ if (!latest) return;
165
+
166
+ const { process: proc, release, host, html, data, redis, prewarm } = latest;
167
+
168
+ $("version").textContent = `v${release.version}`;
169
+ $("foot-release").textContent = [
170
+ `v${release.version}`,
171
+ release.license,
172
+ release.node ? `Node ${release.node}` : null,
173
+ ]
174
+ .filter(Boolean)
175
+ .join(" · ");
176
+ if (release.homepage) $("foot-repo").href = release.homepage;
177
+
178
+ $("env").textContent = proc.env;
179
+ $("pid").textContent = `pid ${proc.pid}`;
180
+ $("uptime").textContent = t("chip.up", { value: formatDuration(proc.uptime) });
181
+ $("memory").textContent = `rss ${formatBytes(proc.memory.rss)}`;
182
+
183
+ $("html-size").textContent = html.size.toLocaleString();
184
+ $("html-sub").textContent = t("card.limit", { count: html.maxEntries.toLocaleString() });
185
+ $("html-bytes").textContent = formatBytes(html.bytes);
186
+ $("html-stale").textContent = t("card.stale", { count: html.stale });
187
+
188
+ $("data-size").textContent = data.size.toLocaleString();
189
+ $("data-sub").textContent = t("card.staleAndLimit", {
190
+ stale: data.stale,
191
+ limit: data.maxEntries.toLocaleString(),
192
+ });
193
+
194
+ const redisState = !redis.enabled
195
+ ? "off"
196
+ : redis.bypassed
197
+ ? "bypassed"
198
+ : redis.connected
199
+ ? "connected"
200
+ : "disconnected";
201
+
202
+ $("redis-state").textContent = t(`state.${redisState}`);
203
+ $("redis-sub").textContent = redis.enabled
204
+ ? t("redis.sub", { address: redis.address, errors: redis.errors })
205
+ : t("redis.disabled");
206
+
207
+ renderRedis(redis, redisState);
208
+ renderHost(host, proc, redis);
209
+
210
+ const done = prewarm.done ?? 0;
211
+ const total = prewarm.total ?? 0;
212
+ $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : t("state.idle");
213
+ $("prewarm-sub").textContent = prewarm.active
214
+ ? t("prewarm.inProgress")
215
+ : total
216
+ ? t("prewarm.lastRound", { done, total })
217
+ : t("prewarm.never");
218
+
219
+ renderTable();
220
+ }
221
+
222
+ /* ------------------------------------------------------------- cloudflare */
223
+
224
+ /** @type {any} */
225
+ let cloudflare = null;
226
+
227
+ /**
228
+ * Cloudflare genel bakışı ayrı bir uçtan gelir: zone bilgisi ve ayarlar ağa
229
+ * çıkıyor, döküm turu ise saniyeler mertebesinde yenileniyor. Bu yüzden
230
+ * yalnızca açılışta ve bir işlemden sonra istenir.
231
+ *
232
+ * @param {boolean} [force]
233
+ */
234
+ async function loadCloudflare(force = false) {
235
+ const response = await fetch(`cloudflare${force ? "?force=1" : ""}`);
236
+ if (!alive(response)) return;
237
+
238
+ cloudflare = await response.json().catch(() => null);
239
+ renderCloudflare();
240
+ }
241
+
242
+ function renderCloudflare() {
243
+ if (!cloudflare) return;
244
+
245
+ const ok = cloudflare.ok === true;
246
+ const status = cloudflare.status ?? {};
247
+ const settings = cloudflare.settings ?? {};
248
+ const zone = cloudflare.zone ?? null;
249
+
250
+ $("cf-heading").textContent = ok
251
+ ? [zone?.name, zone?.plan].filter(Boolean).join(" · ") || t("state.connected")
252
+ : status.configured
253
+ ? t("cf.error", { error: cloudflare.error })
254
+ : t("cf.notConnected");
255
+
256
+ $("cf-tools").hidden = !ok;
257
+ $("cf-suggest").hidden = ok || status.configured;
258
+ $("cf-refresh").hidden = !status.configured;
259
+
260
+ if (!ok) {
261
+ $("cf-kv").replaceChildren();
262
+ return;
263
+ }
264
+
265
+ const development = settings.developmentMode === "on";
266
+ $("cf-dev").checked = development;
267
+
268
+ /** @type {[string, string, string?][]} */
269
+ const rows = [
270
+ [t("cf.zone"), `${zone?.name ?? "—"} (${status.zoneId ?? "?"})`],
271
+ [t("cf.plan"), zone?.plan ?? "—"],
272
+ [
273
+ t("cf.token"),
274
+ status.tokenSource === "env" ? t("cf.token.env") : t("cf.token.config"),
275
+ "on",
276
+ ],
277
+ [
278
+ t("cf.devModeLabel"),
279
+ development
280
+ ? t("cf.devMode.on", { time: formatDuration(settings.developmentModeRemaining) })
281
+ : t("state.off"),
282
+ development ? "bad" : "on",
283
+ ],
284
+ [t("cf.cacheLevel"), String(settings.cacheLevel ?? "—")],
285
+ [t("cf.browserTtl"), ttl(settings.browserCacheTtl)],
286
+ [t("cf.edgeTtl"), ttl(settings.edgeCacheTtl)],
287
+ [t("cf.sortQuery"), String(settings.sortQueryString ?? "—")],
288
+ [t("cf.alwaysOnline"), String(settings.alwaysOnline ?? "—")],
289
+ ...feature(t("cf.tiered"), settings.tieredCaching),
290
+ ...feature(t("cf.regionalTiered"), settings.regionalTieredCache),
291
+ ...feature(t("cf.reserve"), settings.cacheReserve),
292
+ ];
293
+
294
+ $("cf-kv").replaceChildren(...rows.map(kvRow));
295
+ }
296
+
297
+ /**
298
+ * Plan dışı kalan bir özellik `null` döner. "off" yazmak yanıltıcı olurdu:
299
+ * kapatılmış değil, o planda hiç yok.
300
+ *
301
+ * @param {string} label
302
+ * @param {string | null} value
303
+ * @returns {[string, string, string?][]}
304
+ */
305
+ function feature(label, value) {
306
+ if (value === null || value === undefined) {
307
+ return [[label, t("cf.unavailablePlan"), "off"]];
308
+ }
309
+ const on = value === "on";
310
+ return [[label, on ? t("state.on") : t("state.off"), on ? "on" : "off"]];
311
+ }
312
+
313
+ /**
314
+ * @param {unknown} seconds
315
+ * @returns {string}
316
+ */
317
+ function ttl(seconds) {
318
+ const value = Number(seconds);
319
+ if (!Number.isFinite(value)) return "—";
320
+ // Cloudflare 0'ı "Respect Existing Headers" olarak kullanıyor.
321
+ if (value === 0) return t("cf.respectOrigin");
322
+ return formatDuration(value);
323
+ }
324
+
325
+ /**
326
+ * @param {[string, string, string?]} row
327
+ * @returns {HTMLDivElement}
328
+ */
329
+ function kvRow([label, value, tone]) {
330
+ const wrap = document.createElement("div");
331
+ const dt = document.createElement("dt");
332
+ dt.textContent = label;
333
+ const dd = document.createElement("dd");
334
+ if (tone) dd.className = tone;
335
+ dd.textContent = value;
336
+ wrap.append(dt, dd);
337
+ return wrap;
338
+ }
339
+
340
+ /**
341
+ * Analitik dökümü. İki soru tek uçtan cevaplanıyor: yol verilirse kolo
342
+ * kırılımı, verilmezse zone genelinde cache durumu dağılımı.
343
+ */
344
+ async function loadCloudflareAnalytics() {
345
+ const path = $("cf-path").value.trim();
346
+ const target = $("cf-report");
347
+
348
+ target.hidden = false;
349
+ target.textContent = t("cf.querying");
350
+
351
+ const response = await fetch("cloudflare/analytics", {
352
+ method: "POST",
353
+ headers: {
354
+ "Content-Type": "application/json",
355
+ "X-JSkelet-Cache-Panel": "1",
356
+ },
357
+ body: JSON.stringify({ path }),
358
+ });
359
+
360
+ if (!alive(response)) return;
361
+
362
+ const result = await response.json().catch(() => null);
363
+
364
+ if (!result?.ok) {
365
+ target.textContent = t("msg.cf.failed", { error: result?.error ?? t("cf.queryFailed") });
366
+ return;
367
+ }
368
+
369
+ // Son dökümü dil değişiminde yeniden kurmak için saklanır: tablo ağdan
370
+ // geliyor ve dil değişti diye Cloudflare'e yeniden gitmek gereksiz.
371
+ lastReport = result;
372
+ renderReport();
373
+ }
374
+
375
+ /** @type {any} */
376
+ let lastReport = null;
377
+
378
+ function renderReport() {
379
+ if (!lastReport) return;
380
+ const result = lastReport;
381
+
382
+ $("cf-report").replaceChildren(
383
+ result.colos ? edgeTable(result) : statusTable(result),
384
+ note(
385
+ result.colos
386
+ ? t("cf.edgeNote", {
387
+ colos: result.colos.length,
388
+ path: result.path,
389
+ hours: result.hours,
390
+ hits: result.hits,
391
+ misses: result.misses,
392
+ })
393
+ : t("cf.zoneNote", { hours: result.hours }),
394
+ ),
395
+ );
396
+ }
397
+
398
+ /**
399
+ * @param {any} result
400
+ * @returns {HTMLTableElement}
401
+ */
402
+ function edgeTable(result) {
403
+ return buildTable(
404
+ [t("cf.colo"), t("cf.fromCache"), t("cf.toOrigin")],
405
+ result.colos.map((/** @type {any} */ row) => [
406
+ row.colo,
407
+ String(row.hits),
408
+ String(row.misses),
409
+ ]),
410
+ );
411
+ }
412
+
413
+ /**
414
+ * @param {any} result
415
+ * @returns {HTMLTableElement}
416
+ */
417
+ function statusTable(result) {
418
+ const total = result.rows.reduce((sum, /** @type {any} */ row) => sum + row.requests, 0) || 1;
419
+
420
+ return buildTable(
421
+ [t("cf.cacheStatus"), t("cf.requests"), t("cf.share"), t("cf.edgeBytes")],
422
+ result.rows.map((/** @type {any} */ row) => [
423
+ row.status,
424
+ row.requests.toLocaleString(),
425
+ `${Math.round((row.requests / total) * 100)}%`,
426
+ formatBytes(row.bytes),
427
+ ]),
428
+ );
429
+ }
430
+
431
+ /**
432
+ * @param {string[]} headers
433
+ * @param {string[][]} rows
434
+ * @returns {HTMLTableElement}
435
+ */
436
+ function buildTable(headers, rows) {
437
+ const table = document.createElement("table");
438
+ const thead = document.createElement("thead");
439
+ const headRow = document.createElement("tr");
440
+
441
+ headers.forEach((label, index) => {
442
+ const th = document.createElement("th");
443
+ th.textContent = label;
444
+ if (index > 0) th.className = "num";
445
+ headRow.append(th);
446
+ });
447
+
448
+ thead.append(headRow);
449
+
450
+ const tbody = document.createElement("tbody");
451
+ for (const row of rows) {
452
+ const tr = document.createElement("tr");
453
+ row.forEach((value, index) => tr.append(cell(value, index > 0 ? "num" : undefined)));
454
+ tbody.append(tr);
455
+ }
456
+
457
+ table.append(thead, tbody);
458
+ return table;
459
+ }
460
+
461
+ /**
462
+ * @param {string} text
463
+ * @returns {HTMLParagraphElement}
464
+ */
465
+ function note(text) {
466
+ const paragraph = document.createElement("p");
467
+ paragraph.className = "note m0";
468
+ paragraph.textContent = text;
469
+ return paragraph;
470
+ }
471
+
472
+ /**
473
+ * Paylaşımlı kademenin künyesi.
474
+ *
475
+ * "Bağlı mı" tek başına yetmiyor: yanlış `namespace` ya da beklenmeyen bir
476
+ * `buildId` ile bağlı bir Redis de hiçbir şey paylaşmıyor gibi görünür. Bu
477
+ * yüzden anahtarın **nerede** durduğu ve hangi türlerin paylaşıldığı da
478
+ * yazılır.
479
+ *
480
+ * @param {any} redis
481
+ * @param {string} state
482
+ */
483
+ function renderRedis(redis, state) {
484
+ $("redis-heading").textContent = redis.enabled ? t(`state.${state}`) : t("state.notConfigured");
485
+ $("redis-actions").hidden = !redis.enabled;
486
+ $("redis-suggest").hidden = redis.enabled;
487
+
488
+ /** @type {[string, string, string?][]} */
489
+ const rows = redis.enabled
490
+ ? [
491
+ [
492
+ t("redis.connection"),
493
+ t(`state.${state}`),
494
+ redis.connected && !redis.bypassed ? "on" : "bad",
495
+ ],
496
+ [t("redis.address"), `${redis.address}${redis.secure ? " (TLS)" : ""}`],
497
+ [t("redis.database"), redis.db ?? t("state.default")],
498
+ [t("redis.keyPrefix"), `${redis.keyPrefix}:${redis.namespace}`],
499
+ [t("redis.buildId"), redis.buildId || "dev"],
500
+ [
501
+ t("redis.sharesHtml"),
502
+ redis.html ? t("state.yes") : t("state.no"),
503
+ redis.html ? "on" : "off",
504
+ ],
505
+ [
506
+ t("redis.sharesData"),
507
+ redis.data ? t("state.yes") : t("state.no"),
508
+ redis.data ? "on" : "off",
509
+ ],
510
+ [
511
+ t("redis.compressed"),
512
+ redis.storeEncoded ? t("redis.compressed.shared") : t("redis.compressed.local"),
513
+ redis.storeEncoded ? "on" : "off",
514
+ ],
515
+ [
516
+ t("redis.broadcast"),
517
+ redis.events
518
+ ? redis.subscribed
519
+ ? t("redis.broadcast.subscribed")
520
+ : t("redis.broadcast.publish")
521
+ : t("state.off"),
522
+ redis.events && redis.subscribed ? "on" : "off",
523
+ ],
524
+ [t("redis.timeout"), `${redis.commandTimeoutMs} ${t("unit.ms")}`],
525
+ [t("redis.errors"), String(redis.errors), redis.errors ? "bad" : "on"],
526
+ ]
527
+ : [
528
+ [t("redis.tier"), t("redis.tier.inProcess")],
529
+ [t("redis.replicas"), t("state.none")],
530
+ [t("redis.broadcast"), t("redis.broadcast.local"), "off"],
531
+ ];
532
+
533
+ $("redis-kv").replaceChildren(...rows.map(kvRow));
534
+ }
535
+
536
+ /**
537
+ * Makinenin RAM ve disk durumu. Paylaşımlı kademe kapalıyken önbelleğin
538
+ * tamamı bu sürecin belleğinde yaşıyor; `maxEntries` ile RAM arasındaki
539
+ * ilişkiyi görmeden ayar yapmak körlemesine oluyor.
540
+ *
541
+ * @param {any} host
542
+ * @param {any} proc
543
+ * @param {any} redis
544
+ */
545
+ function renderHost(host, proc, redis) {
546
+ $("host-heading").textContent = [
547
+ host.platform,
548
+ `${host.cpus} cpu`,
549
+ host.load ? `load ${host.load.toFixed(2)}` : null,
550
+ ]
551
+ .filter(Boolean)
552
+ .join(" · ");
553
+
554
+ const ramUsed = host.memory.used / host.memory.total;
555
+ $("ram-text").textContent =
556
+ `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}`;
557
+ meter("ram-bar", ramUsed);
558
+ $("ram-note").textContent = redis.connected
559
+ ? t("host.ramShared", { rss: formatBytes(proc.memory.rss) })
560
+ : t("host.ramOnly", {
561
+ rss: formatBytes(proc.memory.rss),
562
+ html: formatBytes(latest.html.bytes),
563
+ });
564
+
565
+ if (!host.disk) {
566
+ $("disk-text").textContent = t("state.unavailable");
567
+ meter("disk-bar", 0);
568
+ $("disk-note").textContent = t("host.noStats");
569
+ return;
570
+ }
571
+
572
+ const diskUsed = (host.disk.total - host.disk.free) / host.disk.total;
573
+ $("disk-text").textContent =
574
+ `${formatBytes(host.disk.total - host.disk.free)} / ${formatBytes(host.disk.total)}`;
575
+ meter("disk-bar", diskUsed);
576
+ // Önbellek diske yazılmıyor; disk build çıktısı ve log için önemli.
577
+ $("disk-note").textContent = t("host.diskNote", {
578
+ free: formatBytes(host.disk.free),
579
+ path: host.disk.path,
580
+ });
581
+ }
582
+
583
+ /**
584
+ * @param {string} id
585
+ * @param {number} ratio 0–1
586
+ */
587
+ function meter(id, ratio) {
588
+ const bar = $(id);
589
+ const percent = Math.max(0, Math.min(100, ratio * 100));
590
+ bar.style.width = `${percent}%`;
591
+ bar.className = percent >= 90 ? "bad" : percent >= 75 ? "warn" : "";
592
+ }
593
+
594
+ function renderTable() {
595
+ const source = tab === "html" ? latest.html : latest.data;
596
+ const thead = $("thead");
597
+ const tbody = $("tbody");
598
+
599
+ $("listed").textContent =
600
+ source.matched > source.entries.length
601
+ ? t("entries.shownPartial", { shown: source.entries.length, total: source.matched })
602
+ : t("entries.shown", { total: source.matched });
603
+
604
+ const columns =
605
+ tab === "html"
606
+ ? [
607
+ ["entries.key"],
608
+ ["entries.state"],
609
+ ["entries.size", "num"],
610
+ ["entries.status", "num"],
611
+ ["entries.expires", "num"],
612
+ ["entries.deps", "num"],
613
+ ["entries.encodings"],
614
+ [null],
615
+ ]
616
+ : [["entries.key"], ["entries.state"], ["entries.expires", "num"], [null]];
617
+
618
+ const headRow = document.createElement("tr");
619
+ for (const [key, className] of columns) {
620
+ const th = document.createElement("th");
621
+ if (className) th.className = className;
622
+ if (key) th.textContent = t(key);
623
+ headRow.append(th);
624
+ }
625
+ thead.replaceChildren(headRow);
626
+
627
+ tbody.replaceChildren(
628
+ ...source.entries.map((/** @type {any} */ entry) => row(entry)),
629
+ );
630
+
631
+ $("empty").hidden = source.entries.length > 0;
632
+ }
633
+
634
+ /**
635
+ * @param {any} entry
636
+ * @returns {HTMLTableRowElement}
637
+ */
638
+ function row(entry) {
639
+ const tr = document.createElement("tr");
640
+
641
+ const key = document.createElement("td");
642
+ key.className = "key mono";
643
+
644
+ if (tab === "html") {
645
+ // HTML anahtarı gezilebilir bir yol: yeni sekmede açmak, bir sayfanın
646
+ // neden bayat olduğunu anlamanın en kısa yolu. `noreferrer` panelin
647
+ // yolunu `Referer` başlığıyla sızdırmamak için.
648
+ const link = document.createElement("a");
649
+ link.href = entry.url;
650
+ link.target = "_blank";
651
+ link.rel = "noreferrer noopener";
652
+ link.textContent = entry.url;
653
+ key.append(link);
654
+ } else {
655
+ // Veri anahtarı bir URL değil; tıklamanın işe yarar karşılığı onu
656
+ // `clearDataCache()` çağrısına yapıştırabilmek.
657
+ const copy = document.createElement("button");
658
+ copy.className = "as-text";
659
+ copy.type = "button";
660
+ copy.title = t("entries.copyKey");
661
+ copy.textContent = entry.key;
662
+ copy.addEventListener("click", () => void copyKey(entry.key));
663
+ key.append(copy);
664
+ }
665
+
666
+ tr.append(key);
667
+
668
+ const state = document.createElement("td");
669
+ const tag = document.createElement("span");
670
+ tag.className = entry.stale ? "tag stale" : "tag fresh";
671
+ tag.textContent = entry.stale ? t("entries.stale") : t("entries.fresh");
672
+ state.append(tag);
673
+ tr.append(state);
674
+
675
+ if (tab === "html") {
676
+ tr.append(
677
+ cell(formatBytes(entry.bytes), "num"),
678
+ cell(String(entry.status), "num"),
679
+ cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"),
680
+ cell(String(entry.deps), "num"),
681
+ cell(entry.encodings.length ? entry.encodings.join(", ") : "—"),
682
+ );
683
+ } else {
684
+ tr.append(cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"));
685
+ }
686
+
687
+ const actions = document.createElement("td");
688
+ actions.className = "actions";
689
+
690
+ // Tek satırı hem origin'den hem edge'den düşürmek en sık istenen çift
691
+ // işlem; Cloudflare bağlı değilse düğme hiç çıkmaz.
692
+ if (tab === "html" && cloudflare?.ok) {
693
+ const purge = document.createElement("button");
694
+ purge.className = "tiny";
695
+ purge.textContent = t("cf.rowPurge");
696
+ purge.title = t("cf.rowPurgeTitle");
697
+ purge.addEventListener("click", () => {
698
+ void act({ type: "cf:purge-urls", paths: [entry.url] });
699
+ });
700
+ actions.append(purge);
701
+ }
702
+
703
+ const drop = document.createElement("button");
704
+ drop.className = "tiny danger";
705
+ drop.textContent = t("entries.drop");
706
+ drop.addEventListener("click", () => {
707
+ void act({ type: tab === "html" ? "html:drop" : "data:drop", key: entry.key });
708
+ });
709
+
710
+ actions.append(drop);
711
+ tr.append(actions);
712
+
713
+ return tr;
714
+ }
715
+
716
+ /**
717
+ * Panel `http://localhost` dışında da açılabiliyor ve tarayıcı güvensiz
718
+ * kaynaklarda pano API'sini vermiyor; o durumda anahtar seçili bırakılır.
719
+ *
720
+ * @param {string} key
721
+ */
722
+ async function copyKey(key) {
723
+ try {
724
+ await navigator.clipboard.writeText(key);
725
+ toast(t("toast.keyCopied"));
726
+ } catch {
727
+ $("prefix").value = key;
728
+ $("prefix").focus();
729
+ toast(t("toast.clipboard"), false);
730
+ }
731
+ }
732
+
733
+ /**
734
+ * @param {string} text
735
+ * @param {string} [className]
736
+ * @returns {HTMLTableCellElement}
737
+ */
738
+ function cell(text, className) {
739
+ const td = document.createElement("td");
740
+ if (className) td.className = className;
741
+ td.textContent = text;
742
+ return td;
743
+ }
744
+
745
+ /* ------------------------------------------------------------------ olaylar */
746
+
747
+ function startPolling() {
748
+ stopPolling();
749
+ timer = setInterval(load, REFRESH_MS);
750
+ }
751
+
752
+ function stopPolling() {
753
+ if (timer !== null) clearInterval(timer);
754
+ timer = null;
755
+ }
756
+
757
+ $("auto").addEventListener("change", (event) => {
758
+ if (/** @type {HTMLInputElement} */ (event.target).checked) startPolling();
759
+ else stopPolling();
760
+ });
761
+
762
+ $("refresh").addEventListener("click", () => void load());
763
+
764
+ $("logout").addEventListener("click", async () => {
765
+ stopPolling();
766
+ await fetch("logout", { method: "POST" });
767
+ location.reload();
768
+ });
769
+
770
+ // Arama her tuşta sunucuya gitmez: on binlerce anahtarlı bir önbellekte
771
+ // filtreleme sunucuda yapılıyor.
772
+ let searchTimer = 0;
773
+ $("search").addEventListener("input", (event) => {
774
+ query = /** @type {HTMLInputElement} */ (event.target).value;
775
+ clearTimeout(searchTimer);
776
+ searchTimer = setTimeout(load, 200);
777
+ });
778
+
779
+ for (const name of /** @type {const} */ (["html", "data"])) {
780
+ $(`tab-${name}`).addEventListener("click", () => {
781
+ tab = name;
782
+ $("tab-html").setAttribute("aria-selected", String(name === "html"));
783
+ $("tab-data").setAttribute("aria-selected", String(name === "data"));
784
+ renderTable();
785
+ });
786
+ }
787
+
788
+ $("invalidate").addEventListener("click", () => {
789
+ const target = $("target").value.trim();
790
+ if (!target) {
791
+ toast(t("toast.needTarget"), false);
792
+ return;
793
+ }
794
+
795
+ void act({ type: "html:invalidate", target, hard: $("hard").checked });
796
+ });
797
+
798
+ $("clear-html").addEventListener("click", () => {
799
+ if (!confirm(t("confirm.clearHtml"))) return;
800
+ void act({ type: "html:clear" });
801
+ });
802
+
803
+ $("clear-data").addEventListener("click", () => {
804
+ const prefix = $("prefix").value.trim();
805
+ if (!prefix && !confirm(t("confirm.clearData"))) {
806
+ return;
807
+ }
808
+
809
+ void act({ type: "data:clear", prefix });
810
+ });
811
+
812
+ $("prewarm").addEventListener("click", () => void act({ type: "prewarm" }));
813
+
814
+ // Sayım ayrı bir düğme: her döküm turunda tüm keyspace'i taramak Redis'i
815
+ // teşhis uğruna yormak olurdu.
816
+ $("redis-inspect").addEventListener("click", () => void act({ type: "redis:inspect" }));
817
+
818
+ $("redis-html").addEventListener("click", () =>
819
+ void act({ type: "redis:drop", kind: "html" }),
820
+ );
821
+
822
+ $("redis-data").addEventListener("click", () =>
823
+ void act({ type: "redis:drop", kind: "data" }),
824
+ );
825
+
826
+ /* ------------------------------------------------- cloudflare kontrolleri */
827
+
828
+ $("cf-refresh").addEventListener("click", () => void loadCloudflare(true));
829
+
830
+ $("cf-purge-all").addEventListener("click", () => {
831
+ if (!confirm(t("confirm.purgeAll"))) return;
832
+ void act({ type: "cf:purge-everything" });
833
+ });
834
+
835
+ // Bu kurulumda sıcak olan yollar zaten biliniyor; "her şeyi düşür" yerine
836
+ // yalnızca gerçekten servis edilen sayfaları düşürmek edge'i boşa soğutmaz.
837
+ $("cf-purge-cached").addEventListener("click", () => {
838
+ const paths = (latest?.html.entries ?? []).map((/** @type {any} */ entry) => entry.url);
839
+ if (!paths.length) {
840
+ toast(t("toast.noCachedPages"), false);
841
+ return;
842
+ }
843
+
844
+ if (!confirm(t("confirm.purgeUrls", { count: paths.length }))) return;
845
+ void act({ type: "cf:purge-urls", paths });
846
+ });
847
+
848
+ $("cf-purge-keys").addEventListener("click", () => {
849
+ const values = $("cf-values").value.trim();
850
+ if (!values) {
851
+ toast(t("toast.needValue"), false);
852
+ return;
853
+ }
854
+
855
+ void act({ type: "cf:purge-keys", kind: $("cf-kind").value, values });
856
+ });
857
+
858
+ $("cf-dev").addEventListener("change", (event) => {
859
+ const on = /** @type {HTMLInputElement} */ (event.target).checked;
860
+ void act({ type: "cf:setting", id: "development_mode", value: on ? "on" : "off" });
861
+ });
862
+
863
+ $("cf-tiered").addEventListener("click", () => {
864
+ const current = cloudflare?.settings?.tieredCaching;
865
+ void act({ type: "cf:feature", feature: "tiered_caching", value: flip(current) });
866
+ });
867
+
868
+ $("cf-reserve").addEventListener("click", () => {
869
+ const current = cloudflare?.settings?.cacheReserve;
870
+ void act({ type: "cf:feature", feature: "cache_reserve", value: flip(current) });
871
+ });
872
+
873
+ $("cf-reserve-clear").addEventListener("click", () => {
874
+ if (!confirm(t("confirm.clearReserve"))) return;
875
+ void act({ type: "cf:clear-reserve" });
876
+ });
877
+
878
+ $("cf-analytics").addEventListener("click", () => void loadCloudflareAnalytics());
879
+
880
+ /**
881
+ * @param {string | null | undefined} value
882
+ * @returns {"on" | "off"}
883
+ */
884
+ function flip(value) {
885
+ return value === "on" ? "off" : "on";
886
+ }
887
+
888
+ // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
889
+ // dakikalarca boşa istek atmasın.
890
+ document.addEventListener("visibilitychange", () => {
891
+ if (document.hidden) stopPolling();
892
+ else if ($("auto").checked) {
893
+ void load();
894
+ startPolling();
895
+ }
896
+ });
897
+
898
+ /* -------------------------------------------------------------------- dil */
899
+
900
+ // Dil değişimi hiçbir isteğe yol açmaz: statik metinler sözlükten yeniden
901
+ // yazılır, dinamik olanlar elde duran son dökümle tekrar çizilir.
902
+ $("language").append(
903
+ languageSelect(() => {
904
+ applyTranslations();
905
+ if (latest) render();
906
+ renderCloudflare();
907
+ renderReport();
908
+ }),
909
+ );
910
+
911
+ applyTranslations();
912
+
913
+ void load();
914
+ void loadCloudflare();
915
+ startPolling();