jskelet 0.2.1 → 0.2.3

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.
@@ -0,0 +1,808 @@
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
+
12
+ const REFRESH_MS = 3000;
13
+
14
+ const $ = (/** @type {string} */ id) => document.getElementById(id);
15
+
16
+ /** @type {"html" | "data"} */
17
+ let tab = "html";
18
+ let query = "";
19
+ /** @type {any} */
20
+ let latest = null;
21
+ /** @type {number | null} */
22
+ let timer = null;
23
+ let busy = false;
24
+
25
+ /* ------------------------------------------------------------ biçimlendirme */
26
+
27
+ /**
28
+ * @param {number} bytes
29
+ * @returns {string}
30
+ */
31
+ function formatBytes(bytes) {
32
+ if (!bytes) return "0 B";
33
+ const units = ["B", "KB", "MB", "GB"];
34
+ const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
35
+ const value = bytes / 1024 ** power;
36
+ return `${value >= 100 || power === 0 ? Math.round(value) : value.toFixed(1)} ${units[power]}`;
37
+ }
38
+
39
+ /**
40
+ * @param {number} seconds
41
+ * @returns {string}
42
+ */
43
+ function formatDuration(seconds) {
44
+ const total = Math.max(0, Math.round(seconds));
45
+ if (total < 60) return `${total}s`;
46
+ if (total < 3600) return `${Math.floor(total / 60)}m ${total % 60}s`;
47
+ if (total < 86_400) return `${Math.floor(total / 3600)}h ${Math.floor((total % 3600) / 60)}m`;
48
+ return `${Math.floor(total / 86_400)}d ${Math.floor((total % 86_400) / 3600)}h`;
49
+ }
50
+
51
+ /**
52
+ * @param {string} message
53
+ * @param {boolean} [ok]
54
+ */
55
+ function toast(message, ok = true) {
56
+ const node = document.createElement("div");
57
+ node.className = ok ? "toast" : "toast bad";
58
+ node.textContent = message;
59
+ $("toast").append(node);
60
+
61
+ setTimeout(() => node.remove(), 5000);
62
+ }
63
+
64
+ /* -------------------------------------------------------------------- veri */
65
+
66
+ /**
67
+ * Sunucu yetkisiz isteklere 404 dönüyor (panelin varlığını doğrulamamak
68
+ * için). Oturum düştüyse sayfa yeniden yüklenir ve giriş formuna dönülür;
69
+ * arka planda dönmeye devam eden bir yoklama, IP'yi kendi kendine
70
+ * yasaklatırdı.
71
+ *
72
+ * @param {Response} response
73
+ * @returns {boolean} İstek işlenebilir mi.
74
+ */
75
+ function alive(response) {
76
+ if (response.status !== 404) return true;
77
+
78
+ stopPolling();
79
+ location.reload();
80
+ return false;
81
+ }
82
+
83
+ async function load() {
84
+ if (busy) return;
85
+ busy = true;
86
+
87
+ try {
88
+ const response = await fetch(`data?q=${encodeURIComponent(query)}`, {
89
+ headers: { Accept: "application/json" },
90
+ });
91
+ if (!alive(response)) return;
92
+ if (!response.ok) return;
93
+
94
+ latest = await response.json();
95
+ render();
96
+ } catch {
97
+ // Sunucu yeniden başlıyor olabilir; bir sonraki tur dener.
98
+ } finally {
99
+ busy = false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * @param {Record<string, unknown>} body
105
+ * @returns {Promise<void>}
106
+ */
107
+ async function act(body) {
108
+ const response = await fetch("action", {
109
+ method: "POST",
110
+ headers: {
111
+ "Content-Type": "application/json",
112
+ // Panelin CSRF freni: çapraz siteden gönderilemeyen bir başlık.
113
+ "X-JSkelet-Cache-Panel": "1",
114
+ },
115
+ body: JSON.stringify(body),
116
+ });
117
+
118
+ if (!alive(response)) return;
119
+
120
+ const result = await response.json().catch(() => ({ ok: false, message: "Failed" }));
121
+ toast(result.message ?? (result.ok ? "Done" : "Failed"), result.ok !== false);
122
+ await load();
123
+
124
+ // Cloudflare ayarları değişmiş olabilir; genel bakış önbelleği atlanır.
125
+ if (String(body.type ?? "").startsWith("cf:")) await loadCloudflare(true);
126
+ }
127
+
128
+ /* ------------------------------------------------------------------- çizim */
129
+
130
+ function render() {
131
+ if (!latest) return;
132
+
133
+ const { process: proc, release, host, html, data, redis, prewarm } = latest;
134
+
135
+ $("version").textContent = `v${release.version}`;
136
+ $("foot-release").textContent = [
137
+ `v${release.version}`,
138
+ release.license,
139
+ release.node ? `Node ${release.node}` : null,
140
+ ]
141
+ .filter(Boolean)
142
+ .join(" · ");
143
+ if (release.homepage) $("foot-repo").href = release.homepage;
144
+
145
+ $("env").textContent = proc.env;
146
+ $("pid").textContent = `pid ${proc.pid}`;
147
+ $("uptime").textContent = `up ${formatDuration(proc.uptime)}`;
148
+ $("memory").textContent = `rss ${formatBytes(proc.memory.rss)}`;
149
+
150
+ $("html-size").textContent = html.size.toLocaleString();
151
+ $("html-sub").textContent = `limit ${html.maxEntries.toLocaleString()}`;
152
+ $("html-bytes").textContent = formatBytes(html.bytes);
153
+ $("html-stale").textContent = `${html.stale} stale`;
154
+
155
+ $("data-size").textContent = data.size.toLocaleString();
156
+ $("data-sub").textContent = `${data.stale} stale · limit ${data.maxEntries.toLocaleString()}`;
157
+
158
+ const redisState = !redis.enabled
159
+ ? "off"
160
+ : redis.bypassed
161
+ ? "bypassed"
162
+ : redis.connected
163
+ ? "connected"
164
+ : "disconnected";
165
+
166
+ $("redis-state").textContent = redisState;
167
+ $("redis-sub").textContent = redis.enabled
168
+ ? `${redis.address} · ${redis.errors} errors`
169
+ : "cache.redis.enabled is false";
170
+
171
+ renderRedis(redis, redisState);
172
+ renderHost(host, proc, redis);
173
+
174
+ const done = prewarm.done ?? 0;
175
+ const total = prewarm.total ?? 0;
176
+ $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : "idle";
177
+ $("prewarm-sub").textContent = prewarm.active
178
+ ? "round in progress"
179
+ : total
180
+ ? `last round: ${done}/${total}`
181
+ : "no round yet";
182
+
183
+ renderTable();
184
+ }
185
+
186
+ /* ------------------------------------------------------------- cloudflare */
187
+
188
+ /** @type {any} */
189
+ let cloudflare = null;
190
+
191
+ /**
192
+ * Cloudflare genel bakışı ayrı bir uçtan gelir: zone bilgisi ve ayarlar ağa
193
+ * çıkıyor, döküm turu ise saniyeler mertebesinde yenileniyor. Bu yüzden
194
+ * yalnızca açılışta ve bir işlemden sonra istenir.
195
+ *
196
+ * @param {boolean} [force]
197
+ */
198
+ async function loadCloudflare(force = false) {
199
+ const response = await fetch(`cloudflare${force ? "?force=1" : ""}`);
200
+ if (!alive(response)) return;
201
+
202
+ cloudflare = await response.json().catch(() => null);
203
+ renderCloudflare();
204
+ }
205
+
206
+ function renderCloudflare() {
207
+ if (!cloudflare) return;
208
+
209
+ const ok = cloudflare.ok === true;
210
+ const status = cloudflare.status ?? {};
211
+ const settings = cloudflare.settings ?? {};
212
+ const zone = cloudflare.zone ?? null;
213
+
214
+ $("cf-heading").textContent = ok
215
+ ? [zone?.name, zone?.plan].filter(Boolean).join(" · ") || "connected"
216
+ : status.configured
217
+ ? `error: ${cloudflare.error}`
218
+ : "not connected";
219
+
220
+ $("cf-tools").hidden = !ok;
221
+ $("cf-suggest").hidden = ok || status.configured;
222
+ $("cf-refresh").hidden = !status.configured;
223
+
224
+ if (!ok) {
225
+ $("cf-kv").replaceChildren();
226
+ return;
227
+ }
228
+
229
+ const development = settings.developmentMode === "on";
230
+ $("cf-dev").checked = development;
231
+
232
+ /** @type {[string, string, string?][]} */
233
+ const rows = [
234
+ ["Zone", `${zone?.name ?? "—"} (${status.zoneId ?? "?"})`],
235
+ ["Plan", zone?.plan ?? "—"],
236
+ ["Token", status.tokenSource === "env" ? "from environment" : "from config", "on"],
237
+ [
238
+ "Development mode",
239
+ development
240
+ ? `on · ${formatDuration(settings.developmentModeRemaining)} left`
241
+ : "off",
242
+ development ? "bad" : "on",
243
+ ],
244
+ ["Cache level", String(settings.cacheLevel ?? "—")],
245
+ ["Browser cache TTL", ttl(settings.browserCacheTtl)],
246
+ ["Edge cache TTL", ttl(settings.edgeCacheTtl)],
247
+ ["Query string sorting", String(settings.sortQueryString ?? "—")],
248
+ ["Always Online", String(settings.alwaysOnline ?? "—")],
249
+ ...feature("Tiered Cache", settings.tieredCaching),
250
+ ...feature("Regional Tiered Cache", settings.regionalTieredCache),
251
+ ...feature("Cache Reserve", settings.cacheReserve),
252
+ ];
253
+
254
+ $("cf-kv").replaceChildren(...rows.map(kvRow));
255
+ }
256
+
257
+ /**
258
+ * Plan dışı kalan bir özellik `null` döner. "off" yazmak yanıltıcı olurdu:
259
+ * kapatılmış değil, o planda hiç yok.
260
+ *
261
+ * @param {string} label
262
+ * @param {string | null} value
263
+ * @returns {[string, string, string?][]}
264
+ */
265
+ function feature(label, value) {
266
+ if (value === null || value === undefined) {
267
+ return [[label, "unavailable on this plan", "off"]];
268
+ }
269
+ return [[label, value, value === "on" ? "on" : "off"]];
270
+ }
271
+
272
+ /**
273
+ * @param {unknown} seconds
274
+ * @returns {string}
275
+ */
276
+ function ttl(seconds) {
277
+ const value = Number(seconds);
278
+ if (!Number.isFinite(value)) return "—";
279
+ // Cloudflare 0'ı "Respect Existing Headers" olarak kullanıyor.
280
+ if (value === 0) return "respect origin headers";
281
+ return formatDuration(value);
282
+ }
283
+
284
+ /**
285
+ * @param {[string, string, string?]} row
286
+ * @returns {HTMLDivElement}
287
+ */
288
+ function kvRow([label, value, tone]) {
289
+ const wrap = document.createElement("div");
290
+ const dt = document.createElement("dt");
291
+ dt.textContent = label;
292
+ const dd = document.createElement("dd");
293
+ if (tone) dd.className = tone;
294
+ dd.textContent = value;
295
+ wrap.append(dt, dd);
296
+ return wrap;
297
+ }
298
+
299
+ /**
300
+ * Analitik dökümü. İki soru tek uçtan cevaplanıyor: yol verilirse kolo
301
+ * kırılımı, verilmezse zone genelinde cache durumu dağılımı.
302
+ */
303
+ async function loadCloudflareAnalytics() {
304
+ const path = $("cf-path").value.trim();
305
+ const target = $("cf-report");
306
+
307
+ target.hidden = false;
308
+ target.textContent = "Querying Cloudflare…";
309
+
310
+ const response = await fetch("cloudflare/analytics", {
311
+ method: "POST",
312
+ headers: {
313
+ "Content-Type": "application/json",
314
+ "X-JSkelet-Cache-Panel": "1",
315
+ },
316
+ body: JSON.stringify({ path }),
317
+ });
318
+
319
+ if (!alive(response)) return;
320
+
321
+ const result = await response.json().catch(() => null);
322
+
323
+ if (!result?.ok) {
324
+ target.textContent = `Cloudflare: ${result?.error ?? "query failed"}`;
325
+ return;
326
+ }
327
+
328
+ target.replaceChildren(
329
+ result.colos ? edgeTable(result) : statusTable(result),
330
+ note(
331
+ result.colos
332
+ ? `${result.colos.length} colos served \`${result.path}\` in the last ` +
333
+ `${result.hours}h — ${result.hits} from cache, ${result.misses} not. ` +
334
+ "Edges that received no request do not appear, even if they hold a copy."
335
+ : `Zone-wide cache status over the last ${result.hours}h. ` +
336
+ "Numbers come from Cloudflare's sampled dataset: ratios are reliable, " +
337
+ "absolute counts are estimates.",
338
+ ),
339
+ );
340
+ }
341
+
342
+ /**
343
+ * @param {any} result
344
+ * @returns {HTMLTableElement}
345
+ */
346
+ function edgeTable(result) {
347
+ return buildTable(
348
+ ["Colo", "From cache", "To origin"],
349
+ result.colos.map((/** @type {any} */ row) => [
350
+ row.colo,
351
+ String(row.hits),
352
+ String(row.misses),
353
+ ]),
354
+ );
355
+ }
356
+
357
+ /**
358
+ * @param {any} result
359
+ * @returns {HTMLTableElement}
360
+ */
361
+ function statusTable(result) {
362
+ const total = result.rows.reduce((sum, /** @type {any} */ row) => sum + row.requests, 0) || 1;
363
+
364
+ return buildTable(
365
+ ["Cache status", "Requests", "Share", "Edge bytes"],
366
+ result.rows.map((/** @type {any} */ row) => [
367
+ row.status,
368
+ row.requests.toLocaleString(),
369
+ `${Math.round((row.requests / total) * 100)}%`,
370
+ formatBytes(row.bytes),
371
+ ]),
372
+ );
373
+ }
374
+
375
+ /**
376
+ * @param {string[]} headers
377
+ * @param {string[][]} rows
378
+ * @returns {HTMLTableElement}
379
+ */
380
+ function buildTable(headers, rows) {
381
+ const table = document.createElement("table");
382
+ const thead = document.createElement("thead");
383
+ const headRow = document.createElement("tr");
384
+
385
+ headers.forEach((label, index) => {
386
+ const th = document.createElement("th");
387
+ th.textContent = label;
388
+ if (index > 0) th.className = "num";
389
+ headRow.append(th);
390
+ });
391
+
392
+ thead.append(headRow);
393
+
394
+ const tbody = document.createElement("tbody");
395
+ for (const row of rows) {
396
+ const tr = document.createElement("tr");
397
+ row.forEach((value, index) => tr.append(cell(value, index > 0 ? "num" : undefined)));
398
+ tbody.append(tr);
399
+ }
400
+
401
+ table.append(thead, tbody);
402
+ return table;
403
+ }
404
+
405
+ /**
406
+ * @param {string} text
407
+ * @returns {HTMLParagraphElement}
408
+ */
409
+ function note(text) {
410
+ const paragraph = document.createElement("p");
411
+ paragraph.className = "note m0";
412
+ paragraph.textContent = text;
413
+ return paragraph;
414
+ }
415
+
416
+ /**
417
+ * Paylaşımlı kademenin künyesi.
418
+ *
419
+ * "Bağlı mı" tek başına yetmiyor: yanlış `namespace` ya da beklenmeyen bir
420
+ * `buildId` ile bağlı bir Redis de hiçbir şey paylaşmıyor gibi görünür. Bu
421
+ * yüzden anahtarın **nerede** durduğu ve hangi türlerin paylaşıldığı da
422
+ * yazılır.
423
+ *
424
+ * @param {any} redis
425
+ * @param {string} state
426
+ */
427
+ function renderRedis(redis, state) {
428
+ $("redis-heading").textContent = redis.enabled ? state : "not configured";
429
+ $("redis-actions").hidden = !redis.enabled;
430
+ $("redis-suggest").hidden = redis.enabled;
431
+
432
+ /** @type {[string, string, string?][]} */
433
+ const rows = redis.enabled
434
+ ? [
435
+ ["Connection", state, redis.connected && !redis.bypassed ? "on" : "bad"],
436
+ ["Address", `${redis.address}${redis.secure ? " (TLS)" : ""}`],
437
+ ["Database", redis.db ?? "default"],
438
+ ["Key prefix", `${redis.keyPrefix}:${redis.namespace}`],
439
+ ["Build id", redis.buildId || "dev"],
440
+ ["Shares HTML", redis.html ? "yes" : "no", redis.html ? "on" : "off"],
441
+ ["Shares data", redis.data ? "yes" : "no", redis.data ? "on" : "off"],
442
+ [
443
+ "Compressed bodies",
444
+ redis.storeEncoded ? "shared" : "local only",
445
+ redis.storeEncoded ? "on" : "off",
446
+ ],
447
+ [
448
+ "Purge broadcast",
449
+ redis.events ? (redis.subscribed ? "subscribed" : "publish only") : "off",
450
+ redis.events && redis.subscribed ? "on" : "off",
451
+ ],
452
+ ["Command timeout", `${redis.commandTimeoutMs} ms`],
453
+ ["Command errors", String(redis.errors), redis.errors ? "bad" : "on"],
454
+ ]
455
+ : [
456
+ ["Tier", "in-process only"],
457
+ ["Replicas sharing this cache", "none"],
458
+ ["Purge broadcast", "local only", "off"],
459
+ ];
460
+
461
+ $("redis-kv").replaceChildren(...rows.map(kvRow));
462
+ }
463
+
464
+ /**
465
+ * Makinenin RAM ve disk durumu. Paylaşımlı kademe kapalıyken önbelleğin
466
+ * tamamı bu sürecin belleğinde yaşıyor; `maxEntries` ile RAM arasındaki
467
+ * ilişkiyi görmeden ayar yapmak körlemesine oluyor.
468
+ *
469
+ * @param {any} host
470
+ * @param {any} proc
471
+ * @param {any} redis
472
+ */
473
+ function renderHost(host, proc, redis) {
474
+ $("host-heading").textContent = [
475
+ host.platform,
476
+ `${host.cpus} cpu`,
477
+ host.load ? `load ${host.load.toFixed(2)}` : null,
478
+ ]
479
+ .filter(Boolean)
480
+ .join(" · ");
481
+
482
+ const ramUsed = host.memory.used / host.memory.total;
483
+ $("ram-text").textContent =
484
+ `${formatBytes(host.memory.used)} / ${formatBytes(host.memory.total)}`;
485
+ meter("ram-bar", ramUsed);
486
+ $("ram-note").textContent = redis.connected
487
+ ? `This process: ${formatBytes(proc.memory.rss)} RSS.`
488
+ : `This process holds the whole cache: ${formatBytes(proc.memory.rss)} RSS, ` +
489
+ `${formatBytes(latest.html.bytes)} of it HTML.`;
490
+
491
+ if (!host.disk) {
492
+ $("disk-text").textContent = "unavailable";
493
+ meter("disk-bar", 0);
494
+ $("disk-note").textContent = "This platform does not report filesystem stats.";
495
+ return;
496
+ }
497
+
498
+ const diskUsed = (host.disk.total - host.disk.free) / host.disk.total;
499
+ $("disk-text").textContent =
500
+ `${formatBytes(host.disk.total - host.disk.free)} / ${formatBytes(host.disk.total)}`;
501
+ meter("disk-bar", diskUsed);
502
+ // Önbellek diske yazılmıyor; disk build çıktısı ve log için önemli.
503
+ $("disk-note").textContent =
504
+ `${formatBytes(host.disk.free)} free on ${host.disk.path} — build output and logs live here.`;
505
+ }
506
+
507
+ /**
508
+ * @param {string} id
509
+ * @param {number} ratio 0–1
510
+ */
511
+ function meter(id, ratio) {
512
+ const bar = $(id);
513
+ const percent = Math.max(0, Math.min(100, ratio * 100));
514
+ bar.style.width = `${percent}%`;
515
+ bar.className = percent >= 90 ? "bad" : percent >= 75 ? "warn" : "";
516
+ }
517
+
518
+ function renderTable() {
519
+ const source = tab === "html" ? latest.html : latest.data;
520
+ const thead = $("thead");
521
+ const tbody = $("tbody");
522
+
523
+ $("listed").textContent =
524
+ source.matched > source.entries.length
525
+ ? `${source.entries.length} of ${source.matched} shown`
526
+ : `${source.matched} shown`;
527
+
528
+ thead.innerHTML =
529
+ tab === "html"
530
+ ? `<tr><th>Key</th><th>State</th><th class="num">Size</th><th class="num">Status</th><th class="num">Expires</th><th class="num">Deps</th><th>Encodings</th><th></th></tr>`
531
+ : `<tr><th>Key</th><th>State</th><th class="num">Expires</th><th></th></tr>`;
532
+
533
+ tbody.replaceChildren(
534
+ ...source.entries.map((/** @type {any} */ entry) => row(entry)),
535
+ );
536
+
537
+ $("empty").hidden = source.entries.length > 0;
538
+ }
539
+
540
+ /**
541
+ * @param {any} entry
542
+ * @returns {HTMLTableRowElement}
543
+ */
544
+ function row(entry) {
545
+ const tr = document.createElement("tr");
546
+
547
+ const key = document.createElement("td");
548
+ key.className = "key mono";
549
+
550
+ if (tab === "html") {
551
+ // HTML anahtarı gezilebilir bir yol: yeni sekmede açmak, bir sayfanın
552
+ // neden bayat olduğunu anlamanın en kısa yolu. `noreferrer` panelin
553
+ // yolunu `Referer` başlığıyla sızdırmamak için.
554
+ const link = document.createElement("a");
555
+ link.href = entry.url;
556
+ link.target = "_blank";
557
+ link.rel = "noreferrer noopener";
558
+ link.textContent = entry.url;
559
+ key.append(link);
560
+ } else {
561
+ // Veri anahtarı bir URL değil; tıklamanın işe yarar karşılığı onu
562
+ // `clearDataCache()` çağrısına yapıştırabilmek.
563
+ const copy = document.createElement("button");
564
+ copy.className = "as-text";
565
+ copy.type = "button";
566
+ copy.title = "Copy key";
567
+ copy.textContent = entry.key;
568
+ copy.addEventListener("click", () => void copyKey(entry.key));
569
+ key.append(copy);
570
+ }
571
+
572
+ tr.append(key);
573
+
574
+ const state = document.createElement("td");
575
+ const tag = document.createElement("span");
576
+ tag.className = entry.stale ? "tag stale" : "tag fresh";
577
+ tag.textContent = entry.stale ? "stale" : "fresh";
578
+ state.append(tag);
579
+ tr.append(state);
580
+
581
+ if (tab === "html") {
582
+ tr.append(
583
+ cell(formatBytes(entry.bytes), "num"),
584
+ cell(String(entry.status), "num"),
585
+ cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"),
586
+ cell(String(entry.deps), "num"),
587
+ cell(entry.encodings.length ? entry.encodings.join(", ") : "—"),
588
+ );
589
+ } else {
590
+ tr.append(cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"));
591
+ }
592
+
593
+ const actions = document.createElement("td");
594
+ actions.className = "actions";
595
+
596
+ // Tek satırı hem origin'den hem edge'den düşürmek en sık istenen çift
597
+ // işlem; Cloudflare bağlı değilse düğme hiç çıkmaz.
598
+ if (tab === "html" && cloudflare?.ok) {
599
+ const purge = document.createElement("button");
600
+ purge.className = "tiny";
601
+ purge.textContent = "cf purge";
602
+ purge.title = "Purge this URL at Cloudflare";
603
+ purge.addEventListener("click", () => {
604
+ void act({ type: "cf:purge-urls", paths: [entry.url] });
605
+ });
606
+ actions.append(purge);
607
+ }
608
+
609
+ const drop = document.createElement("button");
610
+ drop.className = "tiny danger";
611
+ drop.textContent = "drop";
612
+ drop.addEventListener("click", () => {
613
+ void act({ type: tab === "html" ? "html:drop" : "data:drop", key: entry.key });
614
+ });
615
+
616
+ actions.append(drop);
617
+ tr.append(actions);
618
+
619
+ return tr;
620
+ }
621
+
622
+ /**
623
+ * Panel `http://localhost` dışında da açılabiliyor ve tarayıcı güvensiz
624
+ * kaynaklarda pano API'sini vermiyor; o durumda anahtar seçili bırakılır.
625
+ *
626
+ * @param {string} key
627
+ */
628
+ async function copyKey(key) {
629
+ try {
630
+ await navigator.clipboard.writeText(key);
631
+ toast("Key copied");
632
+ } catch {
633
+ $("prefix").value = key;
634
+ $("prefix").focus();
635
+ toast("Clipboard is unavailable — key moved to the prefix field", false);
636
+ }
637
+ }
638
+
639
+ /**
640
+ * @param {string} text
641
+ * @param {string} [className]
642
+ * @returns {HTMLTableCellElement}
643
+ */
644
+ function cell(text, className) {
645
+ const td = document.createElement("td");
646
+ if (className) td.className = className;
647
+ td.textContent = text;
648
+ return td;
649
+ }
650
+
651
+ /* ------------------------------------------------------------------ olaylar */
652
+
653
+ function startPolling() {
654
+ stopPolling();
655
+ timer = setInterval(load, REFRESH_MS);
656
+ }
657
+
658
+ function stopPolling() {
659
+ if (timer !== null) clearInterval(timer);
660
+ timer = null;
661
+ }
662
+
663
+ $("auto").addEventListener("change", (event) => {
664
+ if (/** @type {HTMLInputElement} */ (event.target).checked) startPolling();
665
+ else stopPolling();
666
+ });
667
+
668
+ $("refresh").addEventListener("click", () => void load());
669
+
670
+ $("logout").addEventListener("click", async () => {
671
+ stopPolling();
672
+ await fetch("logout", { method: "POST" });
673
+ location.reload();
674
+ });
675
+
676
+ // Arama her tuşta sunucuya gitmez: on binlerce anahtarlı bir önbellekte
677
+ // filtreleme sunucuda yapılıyor.
678
+ let searchTimer = 0;
679
+ $("search").addEventListener("input", (event) => {
680
+ query = /** @type {HTMLInputElement} */ (event.target).value;
681
+ clearTimeout(searchTimer);
682
+ searchTimer = setTimeout(load, 200);
683
+ });
684
+
685
+ for (const name of /** @type {const} */ (["html", "data"])) {
686
+ $(`tab-${name}`).addEventListener("click", () => {
687
+ tab = name;
688
+ $("tab-html").setAttribute("aria-selected", String(name === "html"));
689
+ $("tab-data").setAttribute("aria-selected", String(name === "data"));
690
+ renderTable();
691
+ });
692
+ }
693
+
694
+ $("invalidate").addEventListener("click", () => {
695
+ const target = $("target").value.trim();
696
+ if (!target) {
697
+ toast("Enter a path or pattern first.", false);
698
+ return;
699
+ }
700
+
701
+ void act({ type: "html:invalidate", target, hard: $("hard").checked });
702
+ });
703
+
704
+ $("clear-html").addEventListener("click", () => {
705
+ if (!confirm("Clear the entire HTML cache? Every page goes cold.")) return;
706
+ void act({ type: "html:clear" });
707
+ });
708
+
709
+ $("clear-data").addEventListener("click", () => {
710
+ const prefix = $("prefix").value.trim();
711
+ if (!prefix && !confirm("Clear every data entry? Upstream traffic will spike.")) {
712
+ return;
713
+ }
714
+
715
+ void act({ type: "data:clear", prefix });
716
+ });
717
+
718
+ $("prewarm").addEventListener("click", () => void act({ type: "prewarm" }));
719
+
720
+ // Sayım ayrı bir düğme: her döküm turunda tüm keyspace'i taramak Redis'i
721
+ // teşhis uğruna yormak olurdu.
722
+ $("redis-inspect").addEventListener("click", () => void act({ type: "redis:inspect" }));
723
+
724
+ $("redis-html").addEventListener("click", () =>
725
+ void act({ type: "redis:drop", kind: "html" }),
726
+ );
727
+
728
+ $("redis-data").addEventListener("click", () =>
729
+ void act({ type: "redis:drop", kind: "data" }),
730
+ );
731
+
732
+ /* ------------------------------------------------- cloudflare kontrolleri */
733
+
734
+ $("cf-refresh").addEventListener("click", () => void loadCloudflare(true));
735
+
736
+ $("cf-purge-all").addEventListener("click", () => {
737
+ if (!confirm("Purge the entire Cloudflare cache for this zone?")) return;
738
+ void act({ type: "cf:purge-everything" });
739
+ });
740
+
741
+ // Bu kurulumda sıcak olan yollar zaten biliniyor; "her şeyi düşür" yerine
742
+ // yalnızca gerçekten servis edilen sayfaları düşürmek edge'i boşa soğutmaz.
743
+ $("cf-purge-cached").addEventListener("click", () => {
744
+ const paths = (latest?.html.entries ?? []).map((/** @type {any} */ entry) => entry.url);
745
+ if (!paths.length) {
746
+ toast("No cached pages to purge.", false);
747
+ return;
748
+ }
749
+
750
+ if (!confirm(`Purge ${paths.length} URLs at Cloudflare?`)) return;
751
+ void act({ type: "cf:purge-urls", paths });
752
+ });
753
+
754
+ $("cf-purge-keys").addEventListener("click", () => {
755
+ const values = $("cf-values").value.trim();
756
+ if (!values) {
757
+ toast("Enter at least one value.", false);
758
+ return;
759
+ }
760
+
761
+ void act({ type: "cf:purge-keys", kind: $("cf-kind").value, values });
762
+ });
763
+
764
+ $("cf-dev").addEventListener("change", (event) => {
765
+ const on = /** @type {HTMLInputElement} */ (event.target).checked;
766
+ void act({ type: "cf:setting", id: "development_mode", value: on ? "on" : "off" });
767
+ });
768
+
769
+ $("cf-tiered").addEventListener("click", () => {
770
+ const current = cloudflare?.settings?.tieredCaching;
771
+ void act({ type: "cf:feature", feature: "tiered_caching", value: flip(current) });
772
+ });
773
+
774
+ $("cf-reserve").addEventListener("click", () => {
775
+ const current = cloudflare?.settings?.cacheReserve;
776
+ void act({ type: "cf:feature", feature: "cache_reserve", value: flip(current) });
777
+ });
778
+
779
+ $("cf-reserve-clear").addEventListener("click", () => {
780
+ if (!confirm("Clear Cache Reserve? Cloudflare will refetch everything from the origin.")) {
781
+ return;
782
+ }
783
+ void act({ type: "cf:clear-reserve" });
784
+ });
785
+
786
+ $("cf-analytics").addEventListener("click", () => void loadCloudflareAnalytics());
787
+
788
+ /**
789
+ * @param {string | null | undefined} value
790
+ * @returns {"on" | "off"}
791
+ */
792
+ function flip(value) {
793
+ return value === "on" ? "off" : "on";
794
+ }
795
+
796
+ // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
797
+ // dakikalarca boşa istek atmasın.
798
+ document.addEventListener("visibilitychange", () => {
799
+ if (document.hidden) stopPolling();
800
+ else if ($("auto").checked) {
801
+ void load();
802
+ startPolling();
803
+ }
804
+ });
805
+
806
+ void load();
807
+ void loadCloudflare();
808
+ startPolling();