jskelet 0.2.2 → 0.2.4

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.
@@ -7,7 +7,11 @@
7
7
  *
8
8
  * Yenileme WebSocket değil, kısa aralıklı `fetch` ile: panel nadiren ve kısa
9
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.
10
13
  */
14
+ import { applyTranslations, languageSelect, t } from "./i18n.js";
11
15
 
12
16
  const REFRESH_MS = 3000;
13
17
 
@@ -42,10 +46,14 @@ function formatBytes(bytes) {
42
46
  */
43
47
  function formatDuration(seconds) {
44
48
  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
+ 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}`;
49
57
  }
50
58
 
51
59
  /**
@@ -117,9 +125,37 @@ async function act(body) {
117
125
 
118
126
  if (!alive(response)) return;
119
127
 
120
- const result = await response.json().catch(() => ({ ok: false, message: "Failed" }));
121
- toast(result.message ?? (result.ok ? "Done" : "Failed"), result.ok !== false);
128
+ const result = await response.json().catch(() => null);
129
+ toast(describe(result), result?.ok !== false);
122
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");
123
159
  }
124
160
 
125
161
  /* ------------------------------------------------------------------- çizim */
@@ -127,20 +163,33 @@ async function act(body) {
127
163
  function render() {
128
164
  if (!latest) return;
129
165
 
130
- const { process: proc, html, data, redis, prewarm } = latest;
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;
131
177
 
132
178
  $("env").textContent = proc.env;
133
179
  $("pid").textContent = `pid ${proc.pid}`;
134
- $("uptime").textContent = `up ${formatDuration(proc.uptime)}`;
180
+ $("uptime").textContent = t("chip.up", { value: formatDuration(proc.uptime) });
135
181
  $("memory").textContent = `rss ${formatBytes(proc.memory.rss)}`;
136
182
 
137
183
  $("html-size").textContent = html.size.toLocaleString();
138
- $("html-sub").textContent = `limit ${html.maxEntries.toLocaleString()}`;
184
+ $("html-sub").textContent = t("card.limit", { count: html.maxEntries.toLocaleString() });
139
185
  $("html-bytes").textContent = formatBytes(html.bytes);
140
- $("html-stale").textContent = `${html.stale} stale`;
186
+ $("html-stale").textContent = t("card.stale", { count: html.stale });
141
187
 
142
188
  $("data-size").textContent = data.size.toLocaleString();
143
- $("data-sub").textContent = `${data.stale} stale · limit ${data.maxEntries.toLocaleString()}`;
189
+ $("data-sub").textContent = t("card.staleAndLimit", {
190
+ stale: data.stale,
191
+ limit: data.maxEntries.toLocaleString(),
192
+ });
144
193
 
145
194
  const redisState = !redis.enabled
146
195
  ? "off"
@@ -150,23 +199,398 @@ function render() {
150
199
  ? "connected"
151
200
  : "disconnected";
152
201
 
153
- $("redis-state").textContent = redisState;
202
+ $("redis-state").textContent = t(`state.${redisState}`);
154
203
  $("redis-sub").textContent = redis.enabled
155
- ? `${redis.errors} errors · ${redis.keyPrefix || "no prefix"}`
156
- : "cache.redis.enabled is false";
204
+ ? t("redis.sub", { address: redis.address, errors: redis.errors })
205
+ : t("redis.disabled");
206
+
207
+ renderRedis(redis, redisState);
208
+ renderHost(host, proc, redis);
157
209
 
158
210
  const done = prewarm.done ?? 0;
159
211
  const total = prewarm.total ?? 0;
160
- $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : "idle";
212
+ $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : t("state.idle");
161
213
  $("prewarm-sub").textContent = prewarm.active
162
- ? "round in progress"
214
+ ? t("prewarm.inProgress")
163
215
  : total
164
- ? `last round: ${done}/${total}`
165
- : "no round yet";
216
+ ? t("prewarm.lastRound", { done, total })
217
+ : t("prewarm.never");
166
218
 
167
219
  renderTable();
168
220
  }
169
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
+
170
594
  function renderTable() {
171
595
  const source = tab === "html" ? latest.html : latest.data;
172
596
  const thead = $("thead");
@@ -174,13 +598,31 @@ function renderTable() {
174
598
 
175
599
  $("listed").textContent =
176
600
  source.matched > source.entries.length
177
- ? `${source.entries.length} of ${source.matched} shown`
178
- : `${source.matched} shown`;
601
+ ? t("entries.shownPartial", { shown: source.entries.length, total: source.matched })
602
+ : t("entries.shown", { total: source.matched });
179
603
 
180
- thead.innerHTML =
604
+ const columns =
181
605
  tab === "html"
182
- ? `<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>`
183
- : `<tr><th>Key</th><th>State</th><th class="num">Expires</th><th></th></tr>`;
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);
184
626
 
185
627
  tbody.replaceChildren(
186
628
  ...source.entries.map((/** @type {any} */ entry) => row(entry)),
@@ -198,13 +640,35 @@ function row(entry) {
198
640
 
199
641
  const key = document.createElement("td");
200
642
  key.className = "key mono";
201
- key.textContent = entry.key;
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
+
202
666
  tr.append(key);
203
667
 
204
668
  const state = document.createElement("td");
205
669
  const tag = document.createElement("span");
206
670
  tag.className = entry.stale ? "tag stale" : "tag fresh";
207
- tag.textContent = entry.stale ? "stale" : "fresh";
671
+ tag.textContent = entry.stale ? t("entries.stale") : t("entries.fresh");
208
672
  state.append(tag);
209
673
  tr.append(state);
210
674
 
@@ -223,9 +687,22 @@ function row(entry) {
223
687
  const actions = document.createElement("td");
224
688
  actions.className = "actions";
225
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
+
226
703
  const drop = document.createElement("button");
227
704
  drop.className = "tiny danger";
228
- drop.textContent = "drop";
705
+ drop.textContent = t("entries.drop");
229
706
  drop.addEventListener("click", () => {
230
707
  void act({ type: tab === "html" ? "html:drop" : "data:drop", key: entry.key });
231
708
  });
@@ -236,6 +713,23 @@ function row(entry) {
236
713
  return tr;
237
714
  }
238
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
+
239
733
  /**
240
734
  * @param {string} text
241
735
  * @param {string} [className]
@@ -294,7 +788,7 @@ for (const name of /** @type {const} */ (["html", "data"])) {
294
788
  $("invalidate").addEventListener("click", () => {
295
789
  const target = $("target").value.trim();
296
790
  if (!target) {
297
- toast("Enter a path or pattern first.", false);
791
+ toast(t("toast.needTarget"), false);
298
792
  return;
299
793
  }
300
794
 
@@ -302,13 +796,13 @@ $("invalidate").addEventListener("click", () => {
302
796
  });
303
797
 
304
798
  $("clear-html").addEventListener("click", () => {
305
- if (!confirm("Clear the entire HTML cache? Every page goes cold.")) return;
799
+ if (!confirm(t("confirm.clearHtml"))) return;
306
800
  void act({ type: "html:clear" });
307
801
  });
308
802
 
309
803
  $("clear-data").addEventListener("click", () => {
310
804
  const prefix = $("prefix").value.trim();
311
- if (!prefix && !confirm("Clear every data entry? Upstream traffic will spike.")) {
805
+ if (!prefix && !confirm(t("confirm.clearData"))) {
312
806
  return;
313
807
  }
314
808
 
@@ -317,6 +811,10 @@ $("clear-data").addEventListener("click", () => {
317
811
 
318
812
  $("prewarm").addEventListener("click", () => void act({ type: "prewarm" }));
319
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
+
320
818
  $("redis-html").addEventListener("click", () =>
321
819
  void act({ type: "redis:drop", kind: "html" }),
322
820
  );
@@ -325,6 +823,68 @@ $("redis-data").addEventListener("click", () =>
325
823
  void act({ type: "redis:drop", kind: "data" }),
326
824
  );
327
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
+
328
888
  // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
329
889
  // dakikalarca boşa istek atmasın.
330
890
  document.addEventListener("visibilitychange", () => {
@@ -335,5 +895,21 @@ document.addEventListener("visibilitychange", () => {
335
895
  }
336
896
  });
337
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
+
338
913
  void load();
914
+ void loadCloudflare();
339
915
  startPolling();