jskelet 0.6.1 → 0.6.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.
Files changed (155) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +620 -596
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +130 -130
  5. package/docs/01-baslangic.md +291 -291
  6. package/docs/02-mimari.md +310 -309
  7. package/docs/03-routing.md +515 -515
  8. package/docs/04-render-ve-sablonlar.md +661 -661
  9. package/docs/05-islands.md +486 -486
  10. package/docs/06-cache.md +1443 -1423
  11. package/docs/07-yapilandirma.md +12 -6
  12. package/docs/08-build.md +429 -428
  13. package/docs/09-dev-araclari.md +364 -364
  14. package/docs/10-dagitim.md +338 -338
  15. package/docs/12-panel-ve-oturum.md +478 -478
  16. package/docs/README.md +83 -83
  17. package/docs/en/01-getting-started.md +298 -298
  18. package/docs/en/02-architecture.md +329 -328
  19. package/docs/en/03-routing.md +531 -531
  20. package/docs/en/04-rendering.md +669 -669
  21. package/docs/en/05-islands.md +497 -497
  22. package/docs/en/06-caching.md +1453 -1431
  23. package/docs/en/07-configuration.md +1219 -1214
  24. package/docs/en/08-build.md +447 -446
  25. package/docs/en/09-dev-tools.md +373 -373
  26. package/docs/en/10-deployment.md +340 -340
  27. package/docs/en/11-migration.md +398 -398
  28. package/docs/en/12-dashboards-and-sessions.md +488 -488
  29. package/docs/en/README.md +87 -87
  30. package/package.json +137 -137
  31. package/src/build/ensure-build.mjs +19 -19
  32. package/src/build/paths.mjs +153 -153
  33. package/src/build/resolve-peer.mjs +36 -36
  34. package/src/build/tasks/client.mjs +349 -349
  35. package/src/build/tasks/css.mjs +235 -235
  36. package/src/build/tasks/fonts.mjs +146 -146
  37. package/src/build/tasks/icons.mjs +357 -357
  38. package/src/build/tasks/images.mjs +244 -244
  39. package/src/build/tasks/precompress.mjs +78 -78
  40. package/src/build/tasks/templates.mjs +20 -20
  41. package/src/client/admin/i18n.js +764 -764
  42. package/src/client/admin/login.html +74 -74
  43. package/src/client/admin/panel.css +809 -809
  44. package/src/client/admin/panel.html +495 -495
  45. package/src/client/admin/panel.js +1251 -1251
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +745 -745
  48. package/src/client/devtools/seo.js +628 -628
  49. package/src/client/dom.js +95 -95
  50. package/src/client/form.js +192 -192
  51. package/src/client/index.js +45 -45
  52. package/src/client/registry.js +305 -305
  53. package/src/client/safe-image.js +91 -91
  54. package/src/client/shared-cookie.js +225 -225
  55. package/src/client/store.js +36 -36
  56. package/src/client/swap.js +188 -188
  57. package/src/compile/codegen.js +336 -336
  58. package/src/compile/compile-all.js +149 -149
  59. package/src/compile/errors.js +66 -66
  60. package/src/compile/expr.js +409 -409
  61. package/src/compile/index.js +17 -17
  62. package/src/compile/parse.js +541 -541
  63. package/src/compile/resolve.js +211 -211
  64. package/src/compile/scan-exports.js +51 -51
  65. package/src/config/defaults.js +17 -1
  66. package/src/config/index.js +13 -0
  67. package/src/config/pattern.js +107 -107
  68. package/src/generate.mjs +163 -163
  69. package/src/http/control-flow.js +71 -71
  70. package/src/http/cookies-entry.js +21 -21
  71. package/src/http/cookies.js +277 -277
  72. package/src/http/request-cache.js +46 -46
  73. package/src/http/request-context.js +165 -165
  74. package/src/http/shared-cookie.js +178 -178
  75. package/src/index.js +101 -101
  76. package/src/init.mjs +230 -230
  77. package/src/migrate/apply.mjs +262 -262
  78. package/src/migrate/babel.mjs +79 -79
  79. package/src/migrate/classify.mjs +155 -155
  80. package/src/migrate/config.mjs +126 -126
  81. package/src/migrate/fs-walk.mjs +191 -191
  82. package/src/migrate/parse.mjs +26 -26
  83. package/src/migrate/scan.mjs +177 -177
  84. package/src/migrate/transform/expr-source.mjs +168 -168
  85. package/src/migrate/transform/island.mjs +67 -67
  86. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  87. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  88. package/src/migrate/transform/page-split.mjs +435 -435
  89. package/src/migrate/write.mjs +81 -81
  90. package/src/migrate.mjs +171 -171
  91. package/src/runtime/alias-hooks.mjs +119 -119
  92. package/src/runtime/register.mjs +4 -4
  93. package/src/server/admin/actions.js +229 -229
  94. package/src/server/admin/auth.js +125 -125
  95. package/src/server/admin/event-log.js +151 -151
  96. package/src/server/admin/gate.js +209 -209
  97. package/src/server/admin/inventory.js +188 -188
  98. package/src/server/admin/mount.js +56 -56
  99. package/src/server/admin/router.js +216 -216
  100. package/src/server/admin/snapshot.js +241 -241
  101. package/src/server/assets.js +147 -147
  102. package/src/server/auth/handoff.js +309 -309
  103. package/src/server/cache-blob.js +70 -0
  104. package/src/server/cache-deps.js +42 -42
  105. package/src/server/cache-vary.js +113 -113
  106. package/src/server/cloudflare.js +607 -607
  107. package/src/server/create-app.js +366 -366
  108. package/src/server/data-cache.js +553 -462
  109. package/src/server/dev/report.js +485 -485
  110. package/src/server/dev/socket.js +170 -170
  111. package/src/server/dev/version-check.mjs +139 -139
  112. package/src/server/disk-cache.js +233 -0
  113. package/src/server/ejs-adapter.js +59 -59
  114. package/src/server/html-cache.js +1196 -1122
  115. package/src/server/image-optimizer.js +500 -407
  116. package/src/server/logs/access-middleware.js +66 -66
  117. package/src/server/logs/file-sink.js +193 -66
  118. package/src/server/logs/pipeline.js +165 -158
  119. package/src/server/logs/s3-put.js +214 -214
  120. package/src/server/logs/s3-sink.js +112 -112
  121. package/src/server/metadata.js +102 -102
  122. package/src/server/middleware/compression.js +205 -205
  123. package/src/server/middleware/csrf.js +134 -134
  124. package/src/server/middleware/dev-gate.js +75 -75
  125. package/src/server/middleware/headers.js +37 -37
  126. package/src/server/middleware/redirects.js +32 -32
  127. package/src/server/middleware/robots-txt.js +341 -341
  128. package/src/server/middleware/static-precompressed.js +121 -100
  129. package/src/server/middleware/trailing-slash.js +53 -53
  130. package/src/server/middleware/upstream-proxy.js +141 -141
  131. package/src/server/og-image.js +356 -356
  132. package/src/server/port-guard.js +255 -255
  133. package/src/server/prewarm.js +1082 -1058
  134. package/src/server/redis.js +588 -569
  135. package/src/server/render.js +4 -4
  136. package/src/server/router.js +157 -157
  137. package/src/server/status-page.js +265 -265
  138. package/src/server/upstream-limiter.js +376 -376
  139. package/src/server/upstream-tracking.js +166 -166
  140. package/src/shared/cookie-domain.js +66 -66
  141. package/src/start.mjs +22 -22
  142. package/src/templates/layout.ejs +30 -30
  143. package/src/templates/layout.jsk +30 -30
  144. package/src/version.mjs +31 -31
  145. package/src/views/components/loader.js +101 -101
  146. package/src/views/helpers/html.js +102 -102
  147. package/src/views/helpers/tags.js +375 -375
  148. package/types/config/defaults.d.ts +15 -1
  149. package/types/config/index.d.ts +8 -0
  150. package/types/server/cache-blob.d.ts +13 -0
  151. package/types/server/data-cache.d.ts +9 -0
  152. package/types/server/disk-cache.d.ts +36 -0
  153. package/types/server/html-cache.d.ts +26 -3
  154. package/types/server/logs/file-sink.d.ts +16 -5
  155. package/types/server/redis.d.ts +2 -1
@@ -1,1251 +1,1251 @@
1
- /**
2
- * Admin 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. Log sayfası
10
- * ayrıca SSE kullanır.
11
- *
12
- * Bütün görünen metin `i18n.js` üzerinden geçiyor; sunucudan gelen cevaplar da
13
- * metin değil `code` taşıyor, yani dil değişimi tek yerden hallediliyor.
14
- */
15
- import { applyTranslations, languageSelect, t } from "./i18n.js";
16
-
17
- const REFRESH_MS = 3000;
18
- const PAGES = new Set(["overview", "cache", "routes", "views", "logs", "system"]);
19
-
20
- const $ = (/** @type {string} */ id) => document.getElementById(id);
21
-
22
- /** @type {"html" | "data"} */
23
- let tab = "html";
24
- let query = "";
25
- /** @type {any} */
26
- let latest = null;
27
- /** @type {number | null} */
28
- let timer = null;
29
- let busy = false;
30
- /** @type {string} */
31
- let page = "overview";
32
- /** @type {any} */
33
- let routesPayload = null;
34
- /** @type {any} */
35
- let viewsPayload = null;
36
- /** @type {any[]} */
37
- let logBuffer = [];
38
- /** @type {EventSource | null} */
39
- let logSource = null;
40
- let logsPaused = false;
41
-
42
- /* ------------------------------------------------------------ biçimlendirme */
43
-
44
- /**
45
- * @param {number} bytes
46
- * @returns {string}
47
- */
48
- function formatBytes(bytes) {
49
- if (!bytes) return "0 B";
50
- const units = ["B", "KB", "MB", "GB"];
51
- const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
52
- const value = bytes / 1024 ** power;
53
- return `${value >= 100 || power === 0 ? Math.round(value) : value.toFixed(1)} ${units[power]}`;
54
- }
55
-
56
- /**
57
- * @param {number} seconds
58
- * @returns {string}
59
- */
60
- function formatDuration(seconds) {
61
- const total = Math.max(0, Math.round(seconds));
62
- const [s, m, h, d] = [t("unit.second"), t("unit.minute"), t("unit.hour"), t("unit.day")];
63
-
64
- if (total < 60) return `${total}${s}`;
65
- if (total < 3600) return `${Math.floor(total / 60)}${m} ${total % 60}${s}`;
66
- if (total < 86_400) {
67
- return `${Math.floor(total / 3600)}${h} ${Math.floor((total % 3600) / 60)}${m}`;
68
- }
69
- return `${Math.floor(total / 86_400)}${d} ${Math.floor((total % 86_400) / 3600)}${h}`;
70
- }
71
-
72
- /**
73
- * @param {string} message
74
- * @param {boolean} [ok]
75
- */
76
- function toast(message, ok = true) {
77
- const node = document.createElement("div");
78
- node.className = ok ? "toast" : "toast bad";
79
- node.textContent = message;
80
- $("toast").append(node);
81
-
82
- setTimeout(() => node.remove(), 5000);
83
- }
84
-
85
- /* -------------------------------------------------------------------- veri */
86
-
87
- /**
88
- * Sunucu yetkisiz isteklere 404 dönüyor (panelin varlığını doğrulamamak
89
- * için). Oturum düştüyse sayfa yeniden yüklenir ve giriş formuna dönülür;
90
- * arka planda dönmeye devam eden bir yoklama, IP'yi kendi kendine
91
- * yasaklatırdı.
92
- *
93
- * @param {Response} response
94
- * @returns {boolean} İstek işlenebilir mi.
95
- */
96
- function alive(response) {
97
- if (response.status !== 404) return true;
98
-
99
- stopPolling();
100
- location.reload();
101
- return false;
102
- }
103
-
104
- async function load() {
105
- if (busy) return;
106
- busy = true;
107
-
108
- try {
109
- const response = await fetch(`data?q=${encodeURIComponent(query)}`, {
110
- headers: { Accept: "application/json" },
111
- });
112
- if (!alive(response)) return;
113
- if (!response.ok) return;
114
-
115
- latest = await response.json();
116
- render();
117
- } catch {
118
- // Sunucu yeniden başlıyor olabilir; bir sonraki tur dener.
119
- } finally {
120
- busy = false;
121
- }
122
- }
123
-
124
- /**
125
- * @param {Record<string, unknown>} body
126
- * @returns {Promise<void>}
127
- */
128
- async function act(body) {
129
- const response = await fetch("action", {
130
- method: "POST",
131
- headers: {
132
- "Content-Type": "application/json",
133
- // Panelin CSRF freni: çapraz siteden gönderilemeyen bir başlık.
134
- "X-JSkelet-Admin": "1",
135
- },
136
- body: JSON.stringify(body),
137
- });
138
-
139
- if (!alive(response)) return;
140
-
141
- const result = await response.json().catch(() => null);
142
- toast(describe(result), result?.ok !== false);
143
- await load();
144
-
145
- // Cloudflare ayarları değişmiş olabilir; genel bakış önbelleği atlanır.
146
- if (String(body.type ?? "").startsWith("cf:")) await loadCloudflare(true);
147
- }
148
-
149
- /**
150
- * Sunucu cevabını metne çevirir.
151
- *
152
- * `/action` dil taşımıyor: `{ ok, code, params }` dönüyor ve cümle burada
153
- * kuruluyor. Redis sayımı gibi parçalı cevaplar `parts` ile geliyor, çünkü
154
- * "veritabanında kaç anahtar var" bilgisi her zaman okunamıyor ve eksik parçayı
155
- * cümleden düşürmek tek düzgün yol.
156
- *
157
- * @param {any} result
158
- * @returns {string}
159
- */
160
- function describe(result) {
161
- if (!result) return t("toast.failed");
162
-
163
- if (Array.isArray(result.parts)) {
164
- const parts = result.parts
165
- .map((/** @type {any} */ part) => t(`msg.${part.code}`, part.params))
166
- .filter(Boolean);
167
- if (parts.length) return parts.join(" · ");
168
- }
169
-
170
- if (result.code) return t(`msg.${result.code}`, result.params);
171
- return t(result.ok ? "toast.done" : "toast.failed");
172
- }
173
-
174
- /* ------------------------------------------------------------------- çizim */
175
-
176
- function render() {
177
- if (!latest) return;
178
-
179
- const { process: proc, release, host, html, data, redis, prewarm } = latest;
180
-
181
- $("version").textContent = `v${release.version}`;
182
- $("foot-release").textContent = [
183
- `v${release.version}`,
184
- release.license,
185
- release.node ? `Node ${release.node}` : null,
186
- ]
187
- .filter(Boolean)
188
- .join(" · ");
189
- if (release.homepage) $("foot-repo").href = release.homepage;
190
-
191
- $("env").textContent = proc.env;
192
- $("pid").textContent = `pid ${proc.pid}`;
193
- $("uptime").textContent = t("chip.up", { value: formatDuration(proc.uptime) });
194
- $("memory").textContent = `rss ${formatBytes(proc.memory.rss)}`;
195
-
196
- $("html-size").textContent = html.size.toLocaleString();
197
- $("html-sub").textContent = t("card.limit", { count: html.maxEntries.toLocaleString() });
198
- $("html-bytes").textContent = formatBytes(html.bytes);
199
- $("html-stale").textContent = t("card.stale", { count: html.stale });
200
-
201
- $("data-size").textContent = data.size.toLocaleString();
202
- $("data-sub").textContent = t("card.staleAndLimit", {
203
- stale: data.stale,
204
- limit: data.maxEntries.toLocaleString(),
205
- });
206
-
207
- const redisState = !redis.enabled
208
- ? "off"
209
- : redis.bypassed
210
- ? "bypassed"
211
- : redis.connected
212
- ? "connected"
213
- : "disconnected";
214
-
215
- $("redis-state").textContent = t(`state.${redisState}`);
216
- $("redis-sub").textContent = redis.enabled
217
- ? t("redis.sub", { address: redis.address, errors: redis.errors })
218
- : t("redis.disabled");
219
-
220
- renderRedis(redis, redisState);
221
- renderHost(host, proc, redis);
222
- renderUpstream(latest.upstream);
223
-
224
- const done = prewarm.done ?? 0;
225
- const total = prewarm.total ?? 0;
226
- $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : t("state.idle");
227
- $("prewarm-sub").textContent = prewarm.active
228
- ? t("prewarm.inProgress")
229
- : total
230
- ? t("prewarm.lastRound", { done, total })
231
- : t("prewarm.never");
232
-
233
- renderTable();
234
- }
235
-
236
- /**
237
- * @param {any} upstream
238
- */
239
- function renderUpstream(upstream) {
240
- const tbody = $("upstream-tbody");
241
- const empty = $("upstream-empty");
242
- const heading = $("upstream-heading");
243
- if (!tbody || !empty || !heading) return;
244
-
245
- const hosts = Array.isArray(upstream) ? upstream : [];
246
- heading.textContent = String(hosts.length);
247
- tbody.replaceChildren();
248
-
249
- if (!hosts.length) {
250
- empty.hidden = false;
251
- return;
252
- }
253
-
254
- empty.hidden = true;
255
- for (const info of hosts) {
256
- const row = document.createElement("tr");
257
- const state = info?.bypassed
258
- ? "bypassed"
259
- : info?.blockedMs > 0
260
- ? "blocked"
261
- : `${info?.active ?? 0} active`;
262
- row.innerHTML = `<td class="mono">${escapeHtml(String(info.host ?? ""))}</td><td class="mono">${escapeHtml(String(info.rate ?? "—"))}</td><td class="mono">${escapeHtml(String(state))}</td>`;
263
- tbody.append(row);
264
- }
265
- }
266
-
267
- /**
268
- * @param {string} value
269
- * @returns {string}
270
- */
271
- function escapeHtml(value) {
272
- return value
273
- .replaceAll("&", "&amp;")
274
- .replaceAll("<", "&lt;")
275
- .replaceAll(">", "&gt;")
276
- .replaceAll('"', "&quot;");
277
- }
278
-
279
- /* ------------------------------------------------------------- cloudflare */
280
-
281
- /** @type {any} */
282
- let cloudflare = null;
283
-
284
- /**
285
- * Cloudflare genel bakışı ayrı bir uçtan gelir: zone bilgisi ve ayarlar ağa
286
- * çıkıyor, döküm turu ise saniyeler mertebesinde yenileniyor. Bu yüzden
287
- * yalnızca açılışta ve bir işlemden sonra istenir.
288
- *
289
- * @param {boolean} [force]
290
- */
291
- async function loadCloudflare(force = false) {
292
- const response = await fetch(`cloudflare${force ? "?force=1" : ""}`);
293
- if (!alive(response)) return;
294
-
295
- cloudflare = await response.json().catch(() => null);
296
- renderCloudflare();
297
- }
298
-
299
- function renderCloudflare() {
300
- if (!cloudflare) return;
301
-
302
- const ok = cloudflare.ok === true;
303
- const status = cloudflare.status ?? {};
304
- const settings = cloudflare.settings ?? {};
305
- const zone = cloudflare.zone ?? null;
306
-
307
- $("cf-heading").textContent = ok
308
- ? [zone?.name, zone?.plan].filter(Boolean).join(" · ") || t("state.connected")
309
- : status.configured
310
- ? t("cf.error", { error: cloudflare.error })
311
- : t("cf.notConnected");
312
-
313
- $("cf-tools").hidden = !ok;
314
- $("cf-suggest").hidden = ok || status.configured;
315
- $("cf-refresh").hidden = !status.configured;
316
-
317
- if (!ok) {
318
- $("cf-kv").replaceChildren();
319
- return;
320
- }
321
-
322
- const development = settings.developmentMode === "on";
323
- $("cf-dev").checked = development;
324
-
325
- /** @type {[string, string, string?][]} */
326
- const rows = [
327
- [t("cf.zone"), `${zone?.name ?? "—"} (${status.zoneId ?? "?"})`],
328
- [t("cf.plan"), zone?.plan ?? "—"],
329
- [
330
- t("cf.token"),
331
- status.tokenSource === "env" ? t("cf.token.env") : t("cf.token.config"),
332
- "on",
333
- ],
334
- [
335
- t("cf.devModeLabel"),
336
- development
337
- ? t("cf.devMode.on", { time: formatDuration(settings.developmentModeRemaining) })
338
- : t("state.off"),
339
- development ? "bad" : "on",
340
- ],
341
- [t("cf.cacheLevel"), String(settings.cacheLevel ?? "—")],
342
- [t("cf.browserTtl"), ttl(settings.browserCacheTtl)],
343
- [t("cf.edgeTtl"), ttl(settings.edgeCacheTtl)],
344
- [t("cf.sortQuery"), String(settings.sortQueryString ?? "—")],
345
- [t("cf.alwaysOnline"), String(settings.alwaysOnline ?? "—")],
346
- ...feature(t("cf.tiered"), settings.tieredCaching),
347
- ...feature(t("cf.regionalTiered"), settings.regionalTieredCache),
348
- ...feature(t("cf.reserve"), settings.cacheReserve),
349
- ];
350
-
351
- $("cf-kv").replaceChildren(...rows.map(kvRow));
352
- }
353
-
354
- /**
355
- * Plan dışı kalan bir özellik `null` döner. "off" yazmak yanıltıcı olurdu:
356
- * kapatılmış değil, o planda hiç yok.
357
- *
358
- * @param {string} label
359
- * @param {string | null} value
360
- * @returns {[string, string, string?][]}
361
- */
362
- function feature(label, value) {
363
- if (value === null || value === undefined) {
364
- return [[label, t("cf.unavailablePlan"), "off"]];
365
- }
366
- const on = value === "on";
367
- return [[label, on ? t("state.on") : t("state.off"), on ? "on" : "off"]];
368
- }
369
-
370
- /**
371
- * @param {unknown} seconds
372
- * @returns {string}
373
- */
374
- function ttl(seconds) {
375
- const value = Number(seconds);
376
- if (!Number.isFinite(value)) return "—";
377
- // Cloudflare 0'ı "Respect Existing Headers" olarak kullanıyor.
378
- if (value === 0) return t("cf.respectOrigin");
379
- return formatDuration(value);
380
- }
381
-
382
- /**
383
- * @param {[string, string, string?]} row
384
- * @returns {HTMLDivElement}
385
- */
386
- function kvRow([label, value, tone]) {
387
- const wrap = document.createElement("div");
388
- const dt = document.createElement("dt");
389
- dt.textContent = label;
390
- const dd = document.createElement("dd");
391
- if (tone) dd.className = tone;
392
- dd.textContent = value;
393
- wrap.append(dt, dd);
394
- return wrap;
395
- }
396
-
397
- /**
398
- * Analitik dökümü. İki soru tek uçtan cevaplanıyor: yol verilirse kolo
399
- * kırılımı, verilmezse zone genelinde cache durumu dağılımı.
400
- */
401
- async function loadCloudflareAnalytics() {
402
- const path = $("cf-path").value.trim();
403
- const target = $("cf-report");
404
-
405
- target.hidden = false;
406
- target.textContent = t("cf.querying");
407
-
408
- const response = await fetch("cloudflare/analytics", {
409
- method: "POST",
410
- headers: {
411
- "Content-Type": "application/json",
412
- "X-JSkelet-Admin": "1",
413
- },
414
- body: JSON.stringify({ path }),
415
- });
416
-
417
- if (!alive(response)) return;
418
-
419
- const result = await response.json().catch(() => null);
420
-
421
- if (!result?.ok) {
422
- target.textContent = t("msg.cf.failed", { error: result?.error ?? t("cf.queryFailed") });
423
- return;
424
- }
425
-
426
- // Son dökümü dil değişiminde yeniden kurmak için saklanır: tablo ağdan
427
- // geliyor ve dil değişti diye Cloudflare'e yeniden gitmek gereksiz.
428
- lastReport = result;
429
- renderReport();
430
- }
431
-
432
- /** @type {any} */
433
- let lastReport = null;
434
-
435
- function renderReport() {
436
- if (!lastReport) return;
437
- const result = lastReport;
438
-
439
- $("cf-report").replaceChildren(
440
- result.colos ? edgeTable(result) : statusTable(result),
441
- note(
442
- result.colos
443
- ? t("cf.edgeNote", {
444
- colos: result.colos.length,
445
- path: result.path,
446
- hours: result.hours,
447
- hits: result.hits,
448
- misses: result.misses,
449
- })
450
- : t("cf.zoneNote", { hours: result.hours }),
451
- ),
452
- );
453
- }
454
-
455
- /**
456
- * @param {any} result
457
- * @returns {HTMLTableElement}
458
- */
459
- function edgeTable(result) {
460
- return buildTable(
461
- [t("cf.colo"), t("cf.fromCache"), t("cf.toOrigin")],
462
- result.colos.map((/** @type {any} */ row) => [
463
- row.colo,
464
- String(row.hits),
465
- String(row.misses),
466
- ]),
467
- );
468
- }
469
-
470
- /**
471
- * @param {any} result
472
- * @returns {HTMLTableElement}
473
- */
474
- function statusTable(result) {
475
- const total = result.rows.reduce((sum, /** @type {any} */ row) => sum + row.requests, 0) || 1;
476
-
477
- return buildTable(
478
- [t("cf.cacheStatus"), t("cf.requests"), t("cf.share"), t("cf.edgeBytes")],
479
- result.rows.map((/** @type {any} */ row) => [
480
- row.status,
481
- row.requests.toLocaleString(),
482
- `${Math.round((row.requests / total) * 100)}%`,
483
- formatBytes(row.bytes),
484
- ]),
485
- );
486
- }
487
-
488
- /**
489
- * @param {string[]} headers
490
- * @param {string[][]} rows
491
- * @returns {HTMLTableElement}
492
- */
493
- function buildTable(headers, rows) {
494
- const table = document.createElement("table");
495
- const thead = document.createElement("thead");
496
- const headRow = document.createElement("tr");
497
-
498
- headers.forEach((label, index) => {
499
- const th = document.createElement("th");
500
- th.textContent = label;
501
- if (index > 0) th.className = "num";
502
- headRow.append(th);
503
- });
504
-
505
- thead.append(headRow);
506
-
507
- const tbody = document.createElement("tbody");
508
- for (const row of rows) {
509
- const tr = document.createElement("tr");
510
- row.forEach((value, index) => tr.append(cell(value, index > 0 ? "num" : undefined)));
511
- tbody.append(tr);
512
- }
513
-
514
- table.append(thead, tbody);
515
- return table;
516
- }
517
-
518
- /**
519
- * @param {string} text
520
- * @returns {HTMLParagraphElement}
521
- */
522
- function note(text) {
523
- const paragraph = document.createElement("p");
524
- paragraph.className = "note m0";
525
- paragraph.textContent = text;
526
- return paragraph;
527
- }
528
-
529
- /**
530
- * Paylaşımlı kademenin künyesi.
531
- *
532
- * "Bağlı mı" tek başına yetmiyor: yanlış `namespace` ya da beklenmeyen bir
533
- * `buildId` ile bağlı bir Redis de hiçbir şey paylaşmıyor gibi görünür. Bu
534
- * yüzden anahtarın **nerede** durduğu ve hangi türlerin paylaşıldığı da
535
- * yazılır.
536
- *
537
- * @param {any} redis
538
- * @param {string} state
539
- */
540
- function renderRedis(redis, state) {
541
- $("redis-heading").textContent = redis.enabled ? t(`state.${state}`) : t("state.notConfigured");
542
- $("redis-actions").hidden = !redis.enabled;
543
- $("redis-suggest").hidden = redis.enabled;
544
-
545
- /** @type {[string, string, string?][]} */
546
- const rows = redis.enabled
547
- ? [
548
- [
549
- t("redis.connection"),
550
- t(`state.${state}`),
551
- redis.connected && !redis.bypassed ? "on" : "bad",
552
- ],
553
- [t("redis.address"), `${redis.address}${redis.secure ? " (TLS)" : ""}`],
554
- [t("redis.database"), redis.db ?? t("state.default")],
555
- [t("redis.keyPrefix"), `${redis.keyPrefix}:${redis.namespace}`],
556
- [t("redis.buildId"), redis.buildId || "dev"],
557
- [
558
- t("redis.sharesHtml"),
559
- redis.html ? t("state.yes") : t("state.no"),
560
- redis.html ? "on" : "off",
561
- ],
562
- [
563
- t("redis.sharesData"),
564
- redis.data ? t("state.yes") : t("state.no"),
565
- redis.data ? "on" : "off",
566
- ],
567
- [
568
- t("redis.compressed"),
569
- redis.storeEncoded ? t("redis.compressed.shared") : t("redis.compressed.local"),
570
- redis.storeEncoded ? "on" : "off",
571
- ],
572
- [
573
- t("redis.broadcast"),
574
- redis.events
575
- ? redis.subscribed
576
- ? t("redis.broadcast.subscribed")
577
- : t("redis.broadcast.publish")
578
- : t("state.off"),
579
- redis.events && redis.subscribed ? "on" : "off",
580
- ],
581
- [t("redis.timeout"), `${redis.commandTimeoutMs} ${t("unit.ms")}`],
582
- [t("redis.errors"), String(redis.errors), redis.errors ? "bad" : "on"],
583
- ]
584
- : [
585
- [t("redis.tier"), t("redis.tier.inProcess")],
586
- [t("redis.replicas"), t("state.none")],
587
- [t("redis.broadcast"), t("redis.broadcast.local"), "off"],
588
- ];
589
-
590
- $("redis-kv").replaceChildren(...rows.map(kvRow));
591
- }
592
-
593
- /**
594
- * System sayfası: çubuklar bu sürecin makine kapasitesindeki payı
595
- * (CPU / RSS / proje diski), host'un genel doluluğu değil.
596
- *
597
- * @param {any} host
598
- * @param {any} proc
599
- * @param {any} redis
600
- */
601
- function renderHost(host, proc, redis) {
602
- $("host-heading").textContent = [
603
- host.platform,
604
- `${host.cpus} cpu`,
605
- host.load ? `load ${host.load.toFixed(2)}` : null,
606
- ]
607
- .filter(Boolean)
608
- .join(" · ");
609
-
610
- // Çubuklar makine doluluğunu değil, **bu sürecin** payını gösterir.
611
- const cpuPercent = Number(proc.cpuPercent) || 0;
612
- $("cpu-text").textContent = `${cpuPercent.toFixed(1)}%`;
613
- meter("cpu-bar", cpuPercent / 100);
614
- $("cpu-note").textContent = t("host.cpuNote", { cores: host.cpus });
615
-
616
- const ramRatio = host.memory.total > 0 ? proc.memory.rss / host.memory.total : 0;
617
- $("ram-text").textContent =
618
- `${formatBytes(proc.memory.rss)} / ${formatBytes(host.memory.total)}`;
619
- meter("ram-bar", ramRatio);
620
- $("ram-note").textContent = redis.connected
621
- ? t("host.ramShared", {
622
- rss: formatBytes(proc.memory.rss),
623
- heap: formatBytes(proc.memory.heapUsed),
624
- })
625
- : t("host.ramOnly", {
626
- rss: formatBytes(proc.memory.rss),
627
- heap: formatBytes(proc.memory.heapUsed),
628
- html: formatBytes(latest.html.bytes),
629
- });
630
-
631
- if (!host.disk) {
632
- $("disk-text").textContent = t("state.unavailable");
633
- meter("disk-bar", 0);
634
- $("disk-note").textContent = t("host.noStats");
635
- return;
636
- }
637
-
638
- const appDisk = Number(proc.diskBytes) || 0;
639
- const diskRatio = host.disk.total > 0 ? appDisk / host.disk.total : 0;
640
- $("disk-text").textContent =
641
- `${formatBytes(appDisk)} / ${formatBytes(host.disk.total)}`;
642
- meter("disk-bar", diskRatio);
643
- $("disk-note").textContent = t("host.diskNote", {
644
- free: formatBytes(host.disk.free),
645
- path: host.disk.path,
646
- });
647
- }
648
-
649
- /**
650
- * @param {string} id
651
- * @param {number} ratio 0–1
652
- */
653
- function meter(id, ratio) {
654
- const bar = $(id);
655
- const percent = Math.max(0, Math.min(100, ratio * 100));
656
- bar.style.width = `${percent}%`;
657
- bar.className = percent >= 90 ? "bad" : percent >= 75 ? "warn" : "";
658
- }
659
-
660
- function renderTable() {
661
- const source = tab === "html" ? latest.html : latest.data;
662
- const thead = $("thead");
663
- const tbody = $("tbody");
664
-
665
- $("listed").textContent =
666
- source.matched > source.entries.length
667
- ? t("entries.shownPartial", { shown: source.entries.length, total: source.matched })
668
- : t("entries.shown", { total: source.matched });
669
-
670
- const columns =
671
- tab === "html"
672
- ? [
673
- ["entries.key"],
674
- ["entries.state"],
675
- ["entries.size", "num"],
676
- ["entries.status", "num"],
677
- ["entries.expires", "num"],
678
- ["entries.deps", "num"],
679
- ["entries.encodings"],
680
- [null],
681
- ]
682
- : [["entries.key"], ["entries.state"], ["entries.expires", "num"], [null]];
683
-
684
- const headRow = document.createElement("tr");
685
- for (const [key, className] of columns) {
686
- const th = document.createElement("th");
687
- if (className) th.className = className;
688
- if (key) th.textContent = t(key);
689
- headRow.append(th);
690
- }
691
- thead.replaceChildren(headRow);
692
-
693
- tbody.replaceChildren(
694
- ...source.entries.map((/** @type {any} */ entry) => row(entry)),
695
- );
696
-
697
- $("empty").hidden = source.entries.length > 0;
698
- }
699
-
700
- /**
701
- * @param {any} entry
702
- * @returns {HTMLTableRowElement}
703
- */
704
- function row(entry) {
705
- const tr = document.createElement("tr");
706
-
707
- const key = document.createElement("td");
708
- key.className = "key mono";
709
-
710
- if (tab === "html") {
711
- // HTML anahtarı gezilebilir bir yol: yeni sekmede açmak, bir sayfanın
712
- // neden bayat olduğunu anlamanın en kısa yolu. `noreferrer` panelin
713
- // yolunu `Referer` başlığıyla sızdırmamak için.
714
- const link = document.createElement("a");
715
- link.href = entry.url;
716
- link.target = "_blank";
717
- link.rel = "noreferrer noopener";
718
- link.textContent = entry.url;
719
- key.append(link);
720
- } else {
721
- // Veri anahtarı bir URL değil; tıklamanın işe yarar karşılığı onu
722
- // `clearDataCache()` çağrısına yapıştırabilmek.
723
- const copy = document.createElement("button");
724
- copy.className = "as-text";
725
- copy.type = "button";
726
- copy.title = t("entries.copyKey");
727
- copy.textContent = entry.key;
728
- copy.addEventListener("click", () => void copyKey(entry.key));
729
- key.append(copy);
730
- }
731
-
732
- tr.append(key);
733
-
734
- const state = document.createElement("td");
735
- const tag = document.createElement("span");
736
- tag.className = entry.stale ? "tag stale" : "tag fresh";
737
- tag.textContent = entry.stale ? t("entries.stale") : t("entries.fresh");
738
- state.append(tag);
739
- tr.append(state);
740
-
741
- if (tab === "html") {
742
- tr.append(
743
- cell(formatBytes(entry.bytes), "num"),
744
- cell(String(entry.status), "num"),
745
- cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"),
746
- cell(String(entry.deps), "num"),
747
- cell(entry.encodings.length ? entry.encodings.join(", ") : "—"),
748
- );
749
- } else {
750
- tr.append(cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"));
751
- }
752
-
753
- const actions = document.createElement("td");
754
- actions.className = "actions";
755
-
756
- // Tek satırı hem origin'den hem edge'den düşürmek en sık istenen çift
757
- // işlem; Cloudflare bağlı değilse düğme hiç çıkmaz.
758
- if (tab === "html" && cloudflare?.ok) {
759
- const purge = document.createElement("button");
760
- purge.className = "tiny";
761
- purge.textContent = t("cf.rowPurge");
762
- purge.title = t("cf.rowPurgeTitle");
763
- purge.addEventListener("click", () => {
764
- void act({ type: "cf:purge-urls", paths: [entry.url] });
765
- });
766
- actions.append(purge);
767
- }
768
-
769
- const drop = document.createElement("button");
770
- drop.className = "tiny danger";
771
- drop.textContent = t("entries.drop");
772
- drop.addEventListener("click", () => {
773
- void act({ type: tab === "html" ? "html:drop" : "data:drop", key: entry.key });
774
- });
775
-
776
- actions.append(drop);
777
- tr.append(actions);
778
-
779
- return tr;
780
- }
781
-
782
- /**
783
- * Panel `http://localhost` dışında da açılabiliyor ve tarayıcı güvensiz
784
- * kaynaklarda pano API'sini vermiyor; o durumda anahtar seçili bırakılır.
785
- *
786
- * @param {string} key
787
- */
788
- async function copyKey(key) {
789
- try {
790
- await navigator.clipboard.writeText(key);
791
- toast(t("toast.keyCopied"));
792
- } catch {
793
- $("prefix").value = key;
794
- $("prefix").focus();
795
- toast(t("toast.clipboard"), false);
796
- }
797
- }
798
-
799
- /**
800
- * @param {string} text
801
- * @param {string} [className]
802
- * @returns {HTMLTableCellElement}
803
- */
804
- function cell(text, className) {
805
- const td = document.createElement("td");
806
- if (className) td.className = className;
807
- td.textContent = text;
808
- return td;
809
- }
810
-
811
- /* ------------------------------------------------------------------ olaylar */
812
-
813
- function startPolling() {
814
- stopPolling();
815
- timer = setInterval(load, REFRESH_MS);
816
- }
817
-
818
- function stopPolling() {
819
- if (timer !== null) clearInterval(timer);
820
- timer = null;
821
- }
822
-
823
- $("auto").addEventListener("change", (event) => {
824
- if (/** @type {HTMLInputElement} */ (event.target).checked) startPolling();
825
- else stopPolling();
826
- });
827
-
828
- $("refresh").addEventListener("click", () => void load());
829
-
830
- $("logout").addEventListener("click", async () => {
831
- stopPolling();
832
- await fetch("logout", { method: "POST" });
833
- location.reload();
834
- });
835
-
836
- // Arama her tuşta sunucuya gitmez: on binlerce anahtarlı bir önbellekte
837
- // filtreleme sunucuda yapılıyor.
838
- let searchTimer = 0;
839
- $("search").addEventListener("input", (event) => {
840
- query = /** @type {HTMLInputElement} */ (event.target).value;
841
- clearTimeout(searchTimer);
842
- searchTimer = setTimeout(load, 200);
843
- });
844
-
845
- for (const name of /** @type {const} */ (["html", "data"])) {
846
- $(`tab-${name}`).addEventListener("click", () => {
847
- tab = name;
848
- $("tab-html").setAttribute("aria-selected", String(name === "html"));
849
- $("tab-data").setAttribute("aria-selected", String(name === "data"));
850
- renderTable();
851
- });
852
- }
853
-
854
- $("invalidate").addEventListener("click", () => {
855
- const target = $("target").value.trim();
856
- if (!target) {
857
- toast(t("toast.needTarget"), false);
858
- return;
859
- }
860
-
861
- void act({ type: "html:invalidate", target, hard: $("hard").checked });
862
- });
863
-
864
- $("clear-html").addEventListener("click", () => {
865
- if (!confirm(t("confirm.clearHtml"))) return;
866
- void act({ type: "html:clear" });
867
- });
868
-
869
- $("clear-data").addEventListener("click", () => {
870
- const prefix = $("prefix").value.trim();
871
- if (!prefix && !confirm(t("confirm.clearData"))) {
872
- return;
873
- }
874
-
875
- void act({ type: "data:clear", prefix });
876
- });
877
-
878
- $("prewarm").addEventListener("click", () => void act({ type: "prewarm" }));
879
-
880
- // Sayım ayrı bir düğme: her döküm turunda tüm keyspace'i taramak Redis'i
881
- // teşhis uğruna yormak olurdu.
882
- $("redis-inspect").addEventListener("click", () => void act({ type: "redis:inspect" }));
883
-
884
- $("redis-html").addEventListener("click", () =>
885
- void act({ type: "redis:drop", kind: "html" }),
886
- );
887
-
888
- $("redis-data").addEventListener("click", () =>
889
- void act({ type: "redis:drop", kind: "data" }),
890
- );
891
-
892
- /* ------------------------------------------------- cloudflare kontrolleri */
893
-
894
- $("cf-refresh").addEventListener("click", () => void loadCloudflare(true));
895
-
896
- $("cf-purge-all").addEventListener("click", () => {
897
- if (!confirm(t("confirm.purgeAll"))) return;
898
- void act({ type: "cf:purge-everything" });
899
- });
900
-
901
- // Bu kurulumda sıcak olan yollar zaten biliniyor; "her şeyi düşür" yerine
902
- // yalnızca gerçekten servis edilen sayfaları düşürmek edge'i boşa soğutmaz.
903
- $("cf-purge-cached").addEventListener("click", () => {
904
- const paths = (latest?.html.entries ?? []).map((/** @type {any} */ entry) => entry.url);
905
- if (!paths.length) {
906
- toast(t("toast.noCachedPages"), false);
907
- return;
908
- }
909
-
910
- if (!confirm(t("confirm.purgeUrls", { count: paths.length }))) return;
911
- void act({ type: "cf:purge-urls", paths });
912
- });
913
-
914
- $("cf-purge-keys").addEventListener("click", () => {
915
- const values = $("cf-values").value.trim();
916
- if (!values) {
917
- toast(t("toast.needValue"), false);
918
- return;
919
- }
920
-
921
- void act({ type: "cf:purge-keys", kind: $("cf-kind").value, values });
922
- });
923
-
924
- $("cf-dev").addEventListener("change", (event) => {
925
- const on = /** @type {HTMLInputElement} */ (event.target).checked;
926
- void act({ type: "cf:setting", id: "development_mode", value: on ? "on" : "off" });
927
- });
928
-
929
- $("cf-tiered").addEventListener("click", () => {
930
- const current = cloudflare?.settings?.tieredCaching;
931
- void act({ type: "cf:feature", feature: "tiered_caching", value: flip(current) });
932
- });
933
-
934
- $("cf-reserve").addEventListener("click", () => {
935
- const current = cloudflare?.settings?.cacheReserve;
936
- void act({ type: "cf:feature", feature: "cache_reserve", value: flip(current) });
937
- });
938
-
939
- $("cf-reserve-clear").addEventListener("click", () => {
940
- if (!confirm(t("confirm.clearReserve"))) return;
941
- void act({ type: "cf:clear-reserve" });
942
- });
943
-
944
- $("cf-analytics").addEventListener("click", () => void loadCloudflareAnalytics());
945
-
946
- /**
947
- * @param {string | null | undefined} value
948
- * @returns {"on" | "off"}
949
- */
950
- function flip(value) {
951
- return value === "on" ? "off" : "on";
952
- }
953
-
954
- // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
955
- // dakikalarca boşa istek atmasın.
956
- document.addEventListener("visibilitychange", () => {
957
- if (document.hidden) {
958
- stopPolling();
959
- stopLogStream();
960
- } else if ($("auto").checked) {
961
- void load();
962
- startPolling();
963
- if (page === "logs") startLogStream();
964
- }
965
- });
966
-
967
- /* -------------------------------------------------------------- sayfalar */
968
-
969
- /**
970
- * URL yolunun son segmentinden sayfa adı.
971
- * @returns {string}
972
- */
973
- function pageFromLocation() {
974
- const parts = location.pathname.replace(/\/+$/, "").split("/");
975
- const last = parts[parts.length - 1] || "";
976
- if (PAGES.has(last)) return last;
977
- return "overview";
978
- }
979
-
980
- /**
981
- * @param {string} next
982
- * @param {{ push?: boolean }} [options]
983
- */
984
- function showPage(next, options = {}) {
985
- const name = PAGES.has(next) ? next : "overview";
986
- page = name;
987
-
988
- for (const node of document.querySelectorAll(".page")) {
989
- const el = /** @type {HTMLElement} */ (node);
990
- el.hidden = el.dataset.page !== name;
991
- }
992
-
993
- for (const link of document.querySelectorAll("#nav a")) {
994
- const a = /** @type {HTMLAnchorElement} */ (link);
995
- if (a.dataset.page === name) a.setAttribute("aria-current", "page");
996
- else a.removeAttribute("aria-current");
997
- }
998
-
999
- const sse = $("sse-status");
1000
- if (sse) sse.hidden = name !== "logs";
1001
-
1002
- if (options.push !== false) {
1003
- const target = name === "overview" ? "./" : name;
1004
- history.pushState({ page: name }, "", target);
1005
- }
1006
-
1007
- if (name === "routes") void loadRoutes();
1008
- if (name === "views") void loadViews();
1009
- if (name === "logs") startLogStream();
1010
- else stopLogStream();
1011
- }
1012
-
1013
- async function loadRoutes() {
1014
- const response = await fetch("api/routes");
1015
- if (!alive(response)) return;
1016
- if (!response.ok) return;
1017
- routesPayload = await response.json();
1018
- renderRoutes();
1019
- }
1020
-
1021
- async function loadViews() {
1022
- const response = await fetch("api/views");
1023
- if (!alive(response)) return;
1024
- if (!response.ok) return;
1025
- viewsPayload = await response.json();
1026
- renderViews();
1027
- }
1028
-
1029
- function renderRoutes() {
1030
- if (!routesPayload) return;
1031
-
1032
- const routes = routesPayload.routes ?? [];
1033
- const modules = routesPayload.modules ?? [];
1034
- const activity = routesPayload.activity ?? {};
1035
-
1036
- $("routes-count").textContent = String(routes.length);
1037
- $("modules-count").textContent = String(modules.length);
1038
-
1039
- const tbody = $("routes-tbody");
1040
- const empty = $("routes-empty");
1041
- tbody.replaceChildren();
1042
-
1043
- if (!routes.length) {
1044
- empty.hidden = false;
1045
- } else {
1046
- empty.hidden = true;
1047
- for (const route of routes) {
1048
- const key = route.path;
1049
- const act = activity[key] ?? null;
1050
- const row = document.createElement("tr");
1051
- row.innerHTML = `
1052
- <td class="mono">${escapeHtml(route.method)}</td>
1053
- <td class="mono"><button type="button" class="linkish" data-path="${escapeHtml(route.path)}">${escapeHtml(route.path)}</button></td>
1054
- <td class="mono">${act ? act.status : "—"}</td>
1055
- <td class="mono">${act ? Math.round(act.ms) : "—"}</td>
1056
- <td class="mono">${act ? act.count : "—"}</td>`;
1057
- tbody.append(row);
1058
- }
1059
-
1060
- tbody.querySelectorAll("button[data-path]").forEach((button) => {
1061
- button.addEventListener("click", () => {
1062
- const pathFilter = /** @type {HTMLElement} */ (button).dataset.path ?? "";
1063
- $("logs-path").value = pathFilter;
1064
- showPage("logs");
1065
- renderLogs();
1066
- });
1067
- });
1068
- }
1069
-
1070
- const modulesBody = $("modules-tbody");
1071
- modulesBody.replaceChildren();
1072
- for (const mod of modules) {
1073
- const row = document.createElement("tr");
1074
- row.innerHTML = `<td class="mono">${escapeHtml(mod.relative)}</td>`;
1075
- modulesBody.append(row);
1076
- }
1077
- }
1078
-
1079
- function renderViews() {
1080
- if (!viewsPayload) return;
1081
-
1082
- const term = ($("views-search")?.value ?? "").trim().toLowerCase();
1083
- const views = (viewsPayload.views ?? []).filter(
1084
- (/** @type {{ relative: string }} */ view) =>
1085
- !term || view.relative.toLowerCase().includes(term),
1086
- );
1087
-
1088
- $("views-count").textContent = String(views.length);
1089
- const tbody = $("views-tbody");
1090
- const empty = $("views-empty");
1091
- tbody.replaceChildren();
1092
-
1093
- if (!views.length) {
1094
- empty.hidden = false;
1095
- return;
1096
- }
1097
-
1098
- empty.hidden = true;
1099
- for (const view of views) {
1100
- const row = document.createElement("tr");
1101
- row.innerHTML = `<td class="mono">${escapeHtml(view.relative)}</td><td>${escapeHtml(view.kind)}</td>`;
1102
- tbody.append(row);
1103
- }
1104
- }
1105
-
1106
- function startLogStream() {
1107
- if (logSource) return;
1108
-
1109
- const chip = $("sse-status");
1110
- if (chip) {
1111
- chip.hidden = false;
1112
- chip.textContent = t("chip.sse.live");
1113
- }
1114
-
1115
- logSource = new EventSource("api/logs/stream");
1116
- logSource.onmessage = (event) => {
1117
- try {
1118
- const entry = JSON.parse(event.data);
1119
- if (!logsPaused) {
1120
- logBuffer.push(entry);
1121
- if (logBuffer.length > 1000) logBuffer.shift();
1122
- renderLogs();
1123
- }
1124
- } catch {
1125
- // bozuk çerçeve yok sayılır
1126
- }
1127
- };
1128
- logSource.onerror = () => {
1129
- if (chip) chip.textContent = t("chip.sse.error");
1130
- };
1131
- }
1132
-
1133
- function stopLogStream() {
1134
- if (logSource) {
1135
- logSource.close();
1136
- logSource = null;
1137
- }
1138
- const chip = $("sse-status");
1139
- if (chip) {
1140
- chip.textContent = t("chip.sse.off");
1141
- chip.hidden = page !== "logs";
1142
- }
1143
- }
1144
-
1145
- function renderLogs() {
1146
- const list = $("logs-list");
1147
- const empty = $("logs-empty");
1148
- const count = $("logs-count");
1149
- if (!list || !empty || !count) return;
1150
-
1151
- const q = ($("logs-q")?.value ?? "").trim().toLowerCase();
1152
- const kind = $("logs-kind")?.value ?? "";
1153
- const method = $("logs-method")?.value ?? "";
1154
- const statusClass = $("logs-status")?.value ?? "";
1155
- const cache = $("logs-cache")?.value ?? "";
1156
- const pathFilter = ($("logs-path")?.value ?? "").trim().toLowerCase();
1157
-
1158
- const filtered = logBuffer.filter((entry) => {
1159
- if (kind && entry.kind !== kind) return false;
1160
- if (method && entry.method !== method) return false;
1161
- if (statusClass && entry.status != null) {
1162
- if (String(Math.floor(Number(entry.status) / 100)) !== statusClass) return false;
1163
- }
1164
- if (cache && entry.cache !== cache) return false;
1165
- if (pathFilter) {
1166
- const hay = `${entry.path ?? ""} ${entry.route ?? ""} ${entry.url ?? ""}`.toLowerCase();
1167
- if (!hay.includes(pathFilter)) return false;
1168
- }
1169
- if (q) {
1170
- const hay = `${entry.url ?? ""} ${entry.message ?? ""} ${entry.scope ?? ""} ${entry.path ?? ""}`.toLowerCase();
1171
- if (!hay.includes(q)) return false;
1172
- }
1173
- return true;
1174
- });
1175
-
1176
- count.textContent = t("logs.shown", { shown: filtered.length, total: logBuffer.length });
1177
- empty.hidden = filtered.length > 0;
1178
-
1179
- const lines = filtered.slice(-300).map(formatLogLine);
1180
- list.textContent = lines.join("\n");
1181
- list.scrollTop = list.scrollHeight;
1182
- }
1183
-
1184
- /**
1185
- * @param {any} entry
1186
- * @returns {string}
1187
- */
1188
- function formatLogLine(entry) {
1189
- const time = new Date(entry.at).toISOString().slice(11, 19);
1190
- if (entry.kind === "http") {
1191
- const cache = entry.cache ? ` ${entry.cache}` : "";
1192
- const route = entry.route ? ` [${entry.route}]` : "";
1193
- return `${time} ${String(entry.method).padEnd(6)} ${entry.status} ${Math.round(entry.ms)}ms${cache}${route} ${entry.url}`;
1194
- }
1195
- return `${time} ${entry.kind.padEnd(6)} ${entry.scope ?? ""} ${entry.message ?? ""}${entry.note ? ` ${entry.note}` : ""}`;
1196
- }
1197
-
1198
- for (const link of document.querySelectorAll("#nav a")) {
1199
- link.addEventListener("click", (event) => {
1200
- event.preventDefault();
1201
- const next = /** @type {HTMLElement} */ (link).dataset.page ?? "overview";
1202
- showPage(next);
1203
- });
1204
- }
1205
-
1206
- window.addEventListener("popstate", () => {
1207
- showPage(pageFromLocation(), { push: false });
1208
- });
1209
-
1210
- $("views-search")?.addEventListener("input", () => renderViews());
1211
-
1212
- for (const id of ["logs-q", "logs-kind", "logs-method", "logs-status", "logs-cache", "logs-path"]) {
1213
- $(id)?.addEventListener("input", () => renderLogs());
1214
- $(id)?.addEventListener("change", () => renderLogs());
1215
- }
1216
-
1217
- $("logs-pause")?.addEventListener("change", (event) => {
1218
- logsPaused = /** @type {HTMLInputElement} */ (event.target).checked;
1219
- });
1220
-
1221
- $("logs-clear")?.addEventListener("click", () => {
1222
- logBuffer = [];
1223
- renderLogs();
1224
- });
1225
-
1226
- /* -------------------------------------------------------------------- dil */
1227
-
1228
- // Dil değişimi hiçbir isteğe yol açmaz: statik metinler sözlükten yeniden
1229
- // yazılır, dinamik olanlar elde duran son dökümle tekrar çizilir.
1230
- $("language").append(
1231
- languageSelect(() => {
1232
- applyTranslations();
1233
- if (latest) render();
1234
- renderCloudflare();
1235
- renderReport();
1236
- renderRoutes();
1237
- renderViews();
1238
- renderLogs();
1239
- }),
1240
- );
1241
-
1242
- applyTranslations();
1243
-
1244
- const params = new URLSearchParams(location.search);
1245
- if (params.get("path")) $("logs-path").value = params.get("path") ?? "";
1246
- if (params.get("route")) $("logs-path").value = params.get("route") ?? "";
1247
-
1248
- showPage(pageFromLocation(), { push: false });
1249
- void load();
1250
- void loadCloudflare();
1251
- startPolling();
1
+ /**
2
+ * Admin 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. Log sayfası
10
+ * ayrıca SSE kullanır.
11
+ *
12
+ * Bütün görünen metin `i18n.js` üzerinden geçiyor; sunucudan gelen cevaplar da
13
+ * metin değil `code` taşıyor, yani dil değişimi tek yerden hallediliyor.
14
+ */
15
+ import { applyTranslations, languageSelect, t } from "./i18n.js";
16
+
17
+ const REFRESH_MS = 3000;
18
+ const PAGES = new Set(["overview", "cache", "routes", "views", "logs", "system"]);
19
+
20
+ const $ = (/** @type {string} */ id) => document.getElementById(id);
21
+
22
+ /** @type {"html" | "data"} */
23
+ let tab = "html";
24
+ let query = "";
25
+ /** @type {any} */
26
+ let latest = null;
27
+ /** @type {number | null} */
28
+ let timer = null;
29
+ let busy = false;
30
+ /** @type {string} */
31
+ let page = "overview";
32
+ /** @type {any} */
33
+ let routesPayload = null;
34
+ /** @type {any} */
35
+ let viewsPayload = null;
36
+ /** @type {any[]} */
37
+ let logBuffer = [];
38
+ /** @type {EventSource | null} */
39
+ let logSource = null;
40
+ let logsPaused = false;
41
+
42
+ /* ------------------------------------------------------------ biçimlendirme */
43
+
44
+ /**
45
+ * @param {number} bytes
46
+ * @returns {string}
47
+ */
48
+ function formatBytes(bytes) {
49
+ if (!bytes) return "0 B";
50
+ const units = ["B", "KB", "MB", "GB"];
51
+ const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
52
+ const value = bytes / 1024 ** power;
53
+ return `${value >= 100 || power === 0 ? Math.round(value) : value.toFixed(1)} ${units[power]}`;
54
+ }
55
+
56
+ /**
57
+ * @param {number} seconds
58
+ * @returns {string}
59
+ */
60
+ function formatDuration(seconds) {
61
+ const total = Math.max(0, Math.round(seconds));
62
+ const [s, m, h, d] = [t("unit.second"), t("unit.minute"), t("unit.hour"), t("unit.day")];
63
+
64
+ if (total < 60) return `${total}${s}`;
65
+ if (total < 3600) return `${Math.floor(total / 60)}${m} ${total % 60}${s}`;
66
+ if (total < 86_400) {
67
+ return `${Math.floor(total / 3600)}${h} ${Math.floor((total % 3600) / 60)}${m}`;
68
+ }
69
+ return `${Math.floor(total / 86_400)}${d} ${Math.floor((total % 86_400) / 3600)}${h}`;
70
+ }
71
+
72
+ /**
73
+ * @param {string} message
74
+ * @param {boolean} [ok]
75
+ */
76
+ function toast(message, ok = true) {
77
+ const node = document.createElement("div");
78
+ node.className = ok ? "toast" : "toast bad";
79
+ node.textContent = message;
80
+ $("toast").append(node);
81
+
82
+ setTimeout(() => node.remove(), 5000);
83
+ }
84
+
85
+ /* -------------------------------------------------------------------- veri */
86
+
87
+ /**
88
+ * Sunucu yetkisiz isteklere 404 dönüyor (panelin varlığını doğrulamamak
89
+ * için). Oturum düştüyse sayfa yeniden yüklenir ve giriş formuna dönülür;
90
+ * arka planda dönmeye devam eden bir yoklama, IP'yi kendi kendine
91
+ * yasaklatırdı.
92
+ *
93
+ * @param {Response} response
94
+ * @returns {boolean} İstek işlenebilir mi.
95
+ */
96
+ function alive(response) {
97
+ if (response.status !== 404) return true;
98
+
99
+ stopPolling();
100
+ location.reload();
101
+ return false;
102
+ }
103
+
104
+ async function load() {
105
+ if (busy) return;
106
+ busy = true;
107
+
108
+ try {
109
+ const response = await fetch(`data?q=${encodeURIComponent(query)}`, {
110
+ headers: { Accept: "application/json" },
111
+ });
112
+ if (!alive(response)) return;
113
+ if (!response.ok) return;
114
+
115
+ latest = await response.json();
116
+ render();
117
+ } catch {
118
+ // Sunucu yeniden başlıyor olabilir; bir sonraki tur dener.
119
+ } finally {
120
+ busy = false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {Record<string, unknown>} body
126
+ * @returns {Promise<void>}
127
+ */
128
+ async function act(body) {
129
+ const response = await fetch("action", {
130
+ method: "POST",
131
+ headers: {
132
+ "Content-Type": "application/json",
133
+ // Panelin CSRF freni: çapraz siteden gönderilemeyen bir başlık.
134
+ "X-JSkelet-Admin": "1",
135
+ },
136
+ body: JSON.stringify(body),
137
+ });
138
+
139
+ if (!alive(response)) return;
140
+
141
+ const result = await response.json().catch(() => null);
142
+ toast(describe(result), result?.ok !== false);
143
+ await load();
144
+
145
+ // Cloudflare ayarları değişmiş olabilir; genel bakış önbelleği atlanır.
146
+ if (String(body.type ?? "").startsWith("cf:")) await loadCloudflare(true);
147
+ }
148
+
149
+ /**
150
+ * Sunucu cevabını metne çevirir.
151
+ *
152
+ * `/action` dil taşımıyor: `{ ok, code, params }` dönüyor ve cümle burada
153
+ * kuruluyor. Redis sayımı gibi parçalı cevaplar `parts` ile geliyor, çünkü
154
+ * "veritabanında kaç anahtar var" bilgisi her zaman okunamıyor ve eksik parçayı
155
+ * cümleden düşürmek tek düzgün yol.
156
+ *
157
+ * @param {any} result
158
+ * @returns {string}
159
+ */
160
+ function describe(result) {
161
+ if (!result) return t("toast.failed");
162
+
163
+ if (Array.isArray(result.parts)) {
164
+ const parts = result.parts
165
+ .map((/** @type {any} */ part) => t(`msg.${part.code}`, part.params))
166
+ .filter(Boolean);
167
+ if (parts.length) return parts.join(" · ");
168
+ }
169
+
170
+ if (result.code) return t(`msg.${result.code}`, result.params);
171
+ return t(result.ok ? "toast.done" : "toast.failed");
172
+ }
173
+
174
+ /* ------------------------------------------------------------------- çizim */
175
+
176
+ function render() {
177
+ if (!latest) return;
178
+
179
+ const { process: proc, release, host, html, data, redis, prewarm } = latest;
180
+
181
+ $("version").textContent = `v${release.version}`;
182
+ $("foot-release").textContent = [
183
+ `v${release.version}`,
184
+ release.license,
185
+ release.node ? `Node ${release.node}` : null,
186
+ ]
187
+ .filter(Boolean)
188
+ .join(" · ");
189
+ if (release.homepage) $("foot-repo").href = release.homepage;
190
+
191
+ $("env").textContent = proc.env;
192
+ $("pid").textContent = `pid ${proc.pid}`;
193
+ $("uptime").textContent = t("chip.up", { value: formatDuration(proc.uptime) });
194
+ $("memory").textContent = `rss ${formatBytes(proc.memory.rss)}`;
195
+
196
+ $("html-size").textContent = html.size.toLocaleString();
197
+ $("html-sub").textContent = t("card.limit", { count: html.maxEntries.toLocaleString() });
198
+ $("html-bytes").textContent = formatBytes(html.bytes);
199
+ $("html-stale").textContent = t("card.stale", { count: html.stale });
200
+
201
+ $("data-size").textContent = data.size.toLocaleString();
202
+ $("data-sub").textContent = t("card.staleAndLimit", {
203
+ stale: data.stale,
204
+ limit: data.maxEntries.toLocaleString(),
205
+ });
206
+
207
+ const redisState = !redis.enabled
208
+ ? "off"
209
+ : redis.bypassed
210
+ ? "bypassed"
211
+ : redis.connected
212
+ ? "connected"
213
+ : "disconnected";
214
+
215
+ $("redis-state").textContent = t(`state.${redisState}`);
216
+ $("redis-sub").textContent = redis.enabled
217
+ ? t("redis.sub", { address: redis.address, errors: redis.errors })
218
+ : t("redis.disabled");
219
+
220
+ renderRedis(redis, redisState);
221
+ renderHost(host, proc, redis);
222
+ renderUpstream(latest.upstream);
223
+
224
+ const done = prewarm.done ?? 0;
225
+ const total = prewarm.total ?? 0;
226
+ $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : t("state.idle");
227
+ $("prewarm-sub").textContent = prewarm.active
228
+ ? t("prewarm.inProgress")
229
+ : total
230
+ ? t("prewarm.lastRound", { done, total })
231
+ : t("prewarm.never");
232
+
233
+ renderTable();
234
+ }
235
+
236
+ /**
237
+ * @param {any} upstream
238
+ */
239
+ function renderUpstream(upstream) {
240
+ const tbody = $("upstream-tbody");
241
+ const empty = $("upstream-empty");
242
+ const heading = $("upstream-heading");
243
+ if (!tbody || !empty || !heading) return;
244
+
245
+ const hosts = Array.isArray(upstream) ? upstream : [];
246
+ heading.textContent = String(hosts.length);
247
+ tbody.replaceChildren();
248
+
249
+ if (!hosts.length) {
250
+ empty.hidden = false;
251
+ return;
252
+ }
253
+
254
+ empty.hidden = true;
255
+ for (const info of hosts) {
256
+ const row = document.createElement("tr");
257
+ const state = info?.bypassed
258
+ ? "bypassed"
259
+ : info?.blockedMs > 0
260
+ ? "blocked"
261
+ : `${info?.active ?? 0} active`;
262
+ row.innerHTML = `<td class="mono">${escapeHtml(String(info.host ?? ""))}</td><td class="mono">${escapeHtml(String(info.rate ?? "—"))}</td><td class="mono">${escapeHtml(String(state))}</td>`;
263
+ tbody.append(row);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * @param {string} value
269
+ * @returns {string}
270
+ */
271
+ function escapeHtml(value) {
272
+ return value
273
+ .replaceAll("&", "&amp;")
274
+ .replaceAll("<", "&lt;")
275
+ .replaceAll(">", "&gt;")
276
+ .replaceAll('"', "&quot;");
277
+ }
278
+
279
+ /* ------------------------------------------------------------- cloudflare */
280
+
281
+ /** @type {any} */
282
+ let cloudflare = null;
283
+
284
+ /**
285
+ * Cloudflare genel bakışı ayrı bir uçtan gelir: zone bilgisi ve ayarlar ağa
286
+ * çıkıyor, döküm turu ise saniyeler mertebesinde yenileniyor. Bu yüzden
287
+ * yalnızca açılışta ve bir işlemden sonra istenir.
288
+ *
289
+ * @param {boolean} [force]
290
+ */
291
+ async function loadCloudflare(force = false) {
292
+ const response = await fetch(`cloudflare${force ? "?force=1" : ""}`);
293
+ if (!alive(response)) return;
294
+
295
+ cloudflare = await response.json().catch(() => null);
296
+ renderCloudflare();
297
+ }
298
+
299
+ function renderCloudflare() {
300
+ if (!cloudflare) return;
301
+
302
+ const ok = cloudflare.ok === true;
303
+ const status = cloudflare.status ?? {};
304
+ const settings = cloudflare.settings ?? {};
305
+ const zone = cloudflare.zone ?? null;
306
+
307
+ $("cf-heading").textContent = ok
308
+ ? [zone?.name, zone?.plan].filter(Boolean).join(" · ") || t("state.connected")
309
+ : status.configured
310
+ ? t("cf.error", { error: cloudflare.error })
311
+ : t("cf.notConnected");
312
+
313
+ $("cf-tools").hidden = !ok;
314
+ $("cf-suggest").hidden = ok || status.configured;
315
+ $("cf-refresh").hidden = !status.configured;
316
+
317
+ if (!ok) {
318
+ $("cf-kv").replaceChildren();
319
+ return;
320
+ }
321
+
322
+ const development = settings.developmentMode === "on";
323
+ $("cf-dev").checked = development;
324
+
325
+ /** @type {[string, string, string?][]} */
326
+ const rows = [
327
+ [t("cf.zone"), `${zone?.name ?? "—"} (${status.zoneId ?? "?"})`],
328
+ [t("cf.plan"), zone?.plan ?? "—"],
329
+ [
330
+ t("cf.token"),
331
+ status.tokenSource === "env" ? t("cf.token.env") : t("cf.token.config"),
332
+ "on",
333
+ ],
334
+ [
335
+ t("cf.devModeLabel"),
336
+ development
337
+ ? t("cf.devMode.on", { time: formatDuration(settings.developmentModeRemaining) })
338
+ : t("state.off"),
339
+ development ? "bad" : "on",
340
+ ],
341
+ [t("cf.cacheLevel"), String(settings.cacheLevel ?? "—")],
342
+ [t("cf.browserTtl"), ttl(settings.browserCacheTtl)],
343
+ [t("cf.edgeTtl"), ttl(settings.edgeCacheTtl)],
344
+ [t("cf.sortQuery"), String(settings.sortQueryString ?? "—")],
345
+ [t("cf.alwaysOnline"), String(settings.alwaysOnline ?? "—")],
346
+ ...feature(t("cf.tiered"), settings.tieredCaching),
347
+ ...feature(t("cf.regionalTiered"), settings.regionalTieredCache),
348
+ ...feature(t("cf.reserve"), settings.cacheReserve),
349
+ ];
350
+
351
+ $("cf-kv").replaceChildren(...rows.map(kvRow));
352
+ }
353
+
354
+ /**
355
+ * Plan dışı kalan bir özellik `null` döner. "off" yazmak yanıltıcı olurdu:
356
+ * kapatılmış değil, o planda hiç yok.
357
+ *
358
+ * @param {string} label
359
+ * @param {string | null} value
360
+ * @returns {[string, string, string?][]}
361
+ */
362
+ function feature(label, value) {
363
+ if (value === null || value === undefined) {
364
+ return [[label, t("cf.unavailablePlan"), "off"]];
365
+ }
366
+ const on = value === "on";
367
+ return [[label, on ? t("state.on") : t("state.off"), on ? "on" : "off"]];
368
+ }
369
+
370
+ /**
371
+ * @param {unknown} seconds
372
+ * @returns {string}
373
+ */
374
+ function ttl(seconds) {
375
+ const value = Number(seconds);
376
+ if (!Number.isFinite(value)) return "—";
377
+ // Cloudflare 0'ı "Respect Existing Headers" olarak kullanıyor.
378
+ if (value === 0) return t("cf.respectOrigin");
379
+ return formatDuration(value);
380
+ }
381
+
382
+ /**
383
+ * @param {[string, string, string?]} row
384
+ * @returns {HTMLDivElement}
385
+ */
386
+ function kvRow([label, value, tone]) {
387
+ const wrap = document.createElement("div");
388
+ const dt = document.createElement("dt");
389
+ dt.textContent = label;
390
+ const dd = document.createElement("dd");
391
+ if (tone) dd.className = tone;
392
+ dd.textContent = value;
393
+ wrap.append(dt, dd);
394
+ return wrap;
395
+ }
396
+
397
+ /**
398
+ * Analitik dökümü. İki soru tek uçtan cevaplanıyor: yol verilirse kolo
399
+ * kırılımı, verilmezse zone genelinde cache durumu dağılımı.
400
+ */
401
+ async function loadCloudflareAnalytics() {
402
+ const path = $("cf-path").value.trim();
403
+ const target = $("cf-report");
404
+
405
+ target.hidden = false;
406
+ target.textContent = t("cf.querying");
407
+
408
+ const response = await fetch("cloudflare/analytics", {
409
+ method: "POST",
410
+ headers: {
411
+ "Content-Type": "application/json",
412
+ "X-JSkelet-Admin": "1",
413
+ },
414
+ body: JSON.stringify({ path }),
415
+ });
416
+
417
+ if (!alive(response)) return;
418
+
419
+ const result = await response.json().catch(() => null);
420
+
421
+ if (!result?.ok) {
422
+ target.textContent = t("msg.cf.failed", { error: result?.error ?? t("cf.queryFailed") });
423
+ return;
424
+ }
425
+
426
+ // Son dökümü dil değişiminde yeniden kurmak için saklanır: tablo ağdan
427
+ // geliyor ve dil değişti diye Cloudflare'e yeniden gitmek gereksiz.
428
+ lastReport = result;
429
+ renderReport();
430
+ }
431
+
432
+ /** @type {any} */
433
+ let lastReport = null;
434
+
435
+ function renderReport() {
436
+ if (!lastReport) return;
437
+ const result = lastReport;
438
+
439
+ $("cf-report").replaceChildren(
440
+ result.colos ? edgeTable(result) : statusTable(result),
441
+ note(
442
+ result.colos
443
+ ? t("cf.edgeNote", {
444
+ colos: result.colos.length,
445
+ path: result.path,
446
+ hours: result.hours,
447
+ hits: result.hits,
448
+ misses: result.misses,
449
+ })
450
+ : t("cf.zoneNote", { hours: result.hours }),
451
+ ),
452
+ );
453
+ }
454
+
455
+ /**
456
+ * @param {any} result
457
+ * @returns {HTMLTableElement}
458
+ */
459
+ function edgeTable(result) {
460
+ return buildTable(
461
+ [t("cf.colo"), t("cf.fromCache"), t("cf.toOrigin")],
462
+ result.colos.map((/** @type {any} */ row) => [
463
+ row.colo,
464
+ String(row.hits),
465
+ String(row.misses),
466
+ ]),
467
+ );
468
+ }
469
+
470
+ /**
471
+ * @param {any} result
472
+ * @returns {HTMLTableElement}
473
+ */
474
+ function statusTable(result) {
475
+ const total = result.rows.reduce((sum, /** @type {any} */ row) => sum + row.requests, 0) || 1;
476
+
477
+ return buildTable(
478
+ [t("cf.cacheStatus"), t("cf.requests"), t("cf.share"), t("cf.edgeBytes")],
479
+ result.rows.map((/** @type {any} */ row) => [
480
+ row.status,
481
+ row.requests.toLocaleString(),
482
+ `${Math.round((row.requests / total) * 100)}%`,
483
+ formatBytes(row.bytes),
484
+ ]),
485
+ );
486
+ }
487
+
488
+ /**
489
+ * @param {string[]} headers
490
+ * @param {string[][]} rows
491
+ * @returns {HTMLTableElement}
492
+ */
493
+ function buildTable(headers, rows) {
494
+ const table = document.createElement("table");
495
+ const thead = document.createElement("thead");
496
+ const headRow = document.createElement("tr");
497
+
498
+ headers.forEach((label, index) => {
499
+ const th = document.createElement("th");
500
+ th.textContent = label;
501
+ if (index > 0) th.className = "num";
502
+ headRow.append(th);
503
+ });
504
+
505
+ thead.append(headRow);
506
+
507
+ const tbody = document.createElement("tbody");
508
+ for (const row of rows) {
509
+ const tr = document.createElement("tr");
510
+ row.forEach((value, index) => tr.append(cell(value, index > 0 ? "num" : undefined)));
511
+ tbody.append(tr);
512
+ }
513
+
514
+ table.append(thead, tbody);
515
+ return table;
516
+ }
517
+
518
+ /**
519
+ * @param {string} text
520
+ * @returns {HTMLParagraphElement}
521
+ */
522
+ function note(text) {
523
+ const paragraph = document.createElement("p");
524
+ paragraph.className = "note m0";
525
+ paragraph.textContent = text;
526
+ return paragraph;
527
+ }
528
+
529
+ /**
530
+ * Paylaşımlı kademenin künyesi.
531
+ *
532
+ * "Bağlı mı" tek başına yetmiyor: yanlış `namespace` ya da beklenmeyen bir
533
+ * `buildId` ile bağlı bir Redis de hiçbir şey paylaşmıyor gibi görünür. Bu
534
+ * yüzden anahtarın **nerede** durduğu ve hangi türlerin paylaşıldığı da
535
+ * yazılır.
536
+ *
537
+ * @param {any} redis
538
+ * @param {string} state
539
+ */
540
+ function renderRedis(redis, state) {
541
+ $("redis-heading").textContent = redis.enabled ? t(`state.${state}`) : t("state.notConfigured");
542
+ $("redis-actions").hidden = !redis.enabled;
543
+ $("redis-suggest").hidden = redis.enabled;
544
+
545
+ /** @type {[string, string, string?][]} */
546
+ const rows = redis.enabled
547
+ ? [
548
+ [
549
+ t("redis.connection"),
550
+ t(`state.${state}`),
551
+ redis.connected && !redis.bypassed ? "on" : "bad",
552
+ ],
553
+ [t("redis.address"), `${redis.address}${redis.secure ? " (TLS)" : ""}`],
554
+ [t("redis.database"), redis.db ?? t("state.default")],
555
+ [t("redis.keyPrefix"), `${redis.keyPrefix}:${redis.namespace}`],
556
+ [t("redis.buildId"), redis.buildId || "dev"],
557
+ [
558
+ t("redis.sharesHtml"),
559
+ redis.html ? t("state.yes") : t("state.no"),
560
+ redis.html ? "on" : "off",
561
+ ],
562
+ [
563
+ t("redis.sharesData"),
564
+ redis.data ? t("state.yes") : t("state.no"),
565
+ redis.data ? "on" : "off",
566
+ ],
567
+ [
568
+ t("redis.compressed"),
569
+ redis.storeEncoded ? t("redis.compressed.shared") : t("redis.compressed.local"),
570
+ redis.storeEncoded ? "on" : "off",
571
+ ],
572
+ [
573
+ t("redis.broadcast"),
574
+ redis.events
575
+ ? redis.subscribed
576
+ ? t("redis.broadcast.subscribed")
577
+ : t("redis.broadcast.publish")
578
+ : t("state.off"),
579
+ redis.events && redis.subscribed ? "on" : "off",
580
+ ],
581
+ [t("redis.timeout"), `${redis.commandTimeoutMs} ${t("unit.ms")}`],
582
+ [t("redis.errors"), String(redis.errors), redis.errors ? "bad" : "on"],
583
+ ]
584
+ : [
585
+ [t("redis.tier"), t("redis.tier.inProcess")],
586
+ [t("redis.replicas"), t("state.none")],
587
+ [t("redis.broadcast"), t("redis.broadcast.local"), "off"],
588
+ ];
589
+
590
+ $("redis-kv").replaceChildren(...rows.map(kvRow));
591
+ }
592
+
593
+ /**
594
+ * System sayfası: çubuklar bu sürecin makine kapasitesindeki payı
595
+ * (CPU / RSS / proje diski), host'un genel doluluğu değil.
596
+ *
597
+ * @param {any} host
598
+ * @param {any} proc
599
+ * @param {any} redis
600
+ */
601
+ function renderHost(host, proc, redis) {
602
+ $("host-heading").textContent = [
603
+ host.platform,
604
+ `${host.cpus} cpu`,
605
+ host.load ? `load ${host.load.toFixed(2)}` : null,
606
+ ]
607
+ .filter(Boolean)
608
+ .join(" · ");
609
+
610
+ // Çubuklar makine doluluğunu değil, **bu sürecin** payını gösterir.
611
+ const cpuPercent = Number(proc.cpuPercent) || 0;
612
+ $("cpu-text").textContent = `${cpuPercent.toFixed(1)}%`;
613
+ meter("cpu-bar", cpuPercent / 100);
614
+ $("cpu-note").textContent = t("host.cpuNote", { cores: host.cpus });
615
+
616
+ const ramRatio = host.memory.total > 0 ? proc.memory.rss / host.memory.total : 0;
617
+ $("ram-text").textContent =
618
+ `${formatBytes(proc.memory.rss)} / ${formatBytes(host.memory.total)}`;
619
+ meter("ram-bar", ramRatio);
620
+ $("ram-note").textContent = redis.connected
621
+ ? t("host.ramShared", {
622
+ rss: formatBytes(proc.memory.rss),
623
+ heap: formatBytes(proc.memory.heapUsed),
624
+ })
625
+ : t("host.ramOnly", {
626
+ rss: formatBytes(proc.memory.rss),
627
+ heap: formatBytes(proc.memory.heapUsed),
628
+ html: formatBytes(latest.html.bytes),
629
+ });
630
+
631
+ if (!host.disk) {
632
+ $("disk-text").textContent = t("state.unavailable");
633
+ meter("disk-bar", 0);
634
+ $("disk-note").textContent = t("host.noStats");
635
+ return;
636
+ }
637
+
638
+ const appDisk = Number(proc.diskBytes) || 0;
639
+ const diskRatio = host.disk.total > 0 ? appDisk / host.disk.total : 0;
640
+ $("disk-text").textContent =
641
+ `${formatBytes(appDisk)} / ${formatBytes(host.disk.total)}`;
642
+ meter("disk-bar", diskRatio);
643
+ $("disk-note").textContent = t("host.diskNote", {
644
+ free: formatBytes(host.disk.free),
645
+ path: host.disk.path,
646
+ });
647
+ }
648
+
649
+ /**
650
+ * @param {string} id
651
+ * @param {number} ratio 0–1
652
+ */
653
+ function meter(id, ratio) {
654
+ const bar = $(id);
655
+ const percent = Math.max(0, Math.min(100, ratio * 100));
656
+ bar.style.width = `${percent}%`;
657
+ bar.className = percent >= 90 ? "bad" : percent >= 75 ? "warn" : "";
658
+ }
659
+
660
+ function renderTable() {
661
+ const source = tab === "html" ? latest.html : latest.data;
662
+ const thead = $("thead");
663
+ const tbody = $("tbody");
664
+
665
+ $("listed").textContent =
666
+ source.matched > source.entries.length
667
+ ? t("entries.shownPartial", { shown: source.entries.length, total: source.matched })
668
+ : t("entries.shown", { total: source.matched });
669
+
670
+ const columns =
671
+ tab === "html"
672
+ ? [
673
+ ["entries.key"],
674
+ ["entries.state"],
675
+ ["entries.size", "num"],
676
+ ["entries.status", "num"],
677
+ ["entries.expires", "num"],
678
+ ["entries.deps", "num"],
679
+ ["entries.encodings"],
680
+ [null],
681
+ ]
682
+ : [["entries.key"], ["entries.state"], ["entries.expires", "num"], [null]];
683
+
684
+ const headRow = document.createElement("tr");
685
+ for (const [key, className] of columns) {
686
+ const th = document.createElement("th");
687
+ if (className) th.className = className;
688
+ if (key) th.textContent = t(key);
689
+ headRow.append(th);
690
+ }
691
+ thead.replaceChildren(headRow);
692
+
693
+ tbody.replaceChildren(
694
+ ...source.entries.map((/** @type {any} */ entry) => row(entry)),
695
+ );
696
+
697
+ $("empty").hidden = source.entries.length > 0;
698
+ }
699
+
700
+ /**
701
+ * @param {any} entry
702
+ * @returns {HTMLTableRowElement}
703
+ */
704
+ function row(entry) {
705
+ const tr = document.createElement("tr");
706
+
707
+ const key = document.createElement("td");
708
+ key.className = "key mono";
709
+
710
+ if (tab === "html") {
711
+ // HTML anahtarı gezilebilir bir yol: yeni sekmede açmak, bir sayfanın
712
+ // neden bayat olduğunu anlamanın en kısa yolu. `noreferrer` panelin
713
+ // yolunu `Referer` başlığıyla sızdırmamak için.
714
+ const link = document.createElement("a");
715
+ link.href = entry.url;
716
+ link.target = "_blank";
717
+ link.rel = "noreferrer noopener";
718
+ link.textContent = entry.url;
719
+ key.append(link);
720
+ } else {
721
+ // Veri anahtarı bir URL değil; tıklamanın işe yarar karşılığı onu
722
+ // `clearDataCache()` çağrısına yapıştırabilmek.
723
+ const copy = document.createElement("button");
724
+ copy.className = "as-text";
725
+ copy.type = "button";
726
+ copy.title = t("entries.copyKey");
727
+ copy.textContent = entry.key;
728
+ copy.addEventListener("click", () => void copyKey(entry.key));
729
+ key.append(copy);
730
+ }
731
+
732
+ tr.append(key);
733
+
734
+ const state = document.createElement("td");
735
+ const tag = document.createElement("span");
736
+ tag.className = entry.stale ? "tag stale" : "tag fresh";
737
+ tag.textContent = entry.stale ? t("entries.stale") : t("entries.fresh");
738
+ state.append(tag);
739
+ tr.append(state);
740
+
741
+ if (tab === "html") {
742
+ tr.append(
743
+ cell(formatBytes(entry.bytes), "num"),
744
+ cell(String(entry.status), "num"),
745
+ cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"),
746
+ cell(String(entry.deps), "num"),
747
+ cell(entry.encodings.length ? entry.encodings.join(", ") : "—"),
748
+ );
749
+ } else {
750
+ tr.append(cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"));
751
+ }
752
+
753
+ const actions = document.createElement("td");
754
+ actions.className = "actions";
755
+
756
+ // Tek satırı hem origin'den hem edge'den düşürmek en sık istenen çift
757
+ // işlem; Cloudflare bağlı değilse düğme hiç çıkmaz.
758
+ if (tab === "html" && cloudflare?.ok) {
759
+ const purge = document.createElement("button");
760
+ purge.className = "tiny";
761
+ purge.textContent = t("cf.rowPurge");
762
+ purge.title = t("cf.rowPurgeTitle");
763
+ purge.addEventListener("click", () => {
764
+ void act({ type: "cf:purge-urls", paths: [entry.url] });
765
+ });
766
+ actions.append(purge);
767
+ }
768
+
769
+ const drop = document.createElement("button");
770
+ drop.className = "tiny danger";
771
+ drop.textContent = t("entries.drop");
772
+ drop.addEventListener("click", () => {
773
+ void act({ type: tab === "html" ? "html:drop" : "data:drop", key: entry.key });
774
+ });
775
+
776
+ actions.append(drop);
777
+ tr.append(actions);
778
+
779
+ return tr;
780
+ }
781
+
782
+ /**
783
+ * Panel `http://localhost` dışında da açılabiliyor ve tarayıcı güvensiz
784
+ * kaynaklarda pano API'sini vermiyor; o durumda anahtar seçili bırakılır.
785
+ *
786
+ * @param {string} key
787
+ */
788
+ async function copyKey(key) {
789
+ try {
790
+ await navigator.clipboard.writeText(key);
791
+ toast(t("toast.keyCopied"));
792
+ } catch {
793
+ $("prefix").value = key;
794
+ $("prefix").focus();
795
+ toast(t("toast.clipboard"), false);
796
+ }
797
+ }
798
+
799
+ /**
800
+ * @param {string} text
801
+ * @param {string} [className]
802
+ * @returns {HTMLTableCellElement}
803
+ */
804
+ function cell(text, className) {
805
+ const td = document.createElement("td");
806
+ if (className) td.className = className;
807
+ td.textContent = text;
808
+ return td;
809
+ }
810
+
811
+ /* ------------------------------------------------------------------ olaylar */
812
+
813
+ function startPolling() {
814
+ stopPolling();
815
+ timer = setInterval(load, REFRESH_MS);
816
+ }
817
+
818
+ function stopPolling() {
819
+ if (timer !== null) clearInterval(timer);
820
+ timer = null;
821
+ }
822
+
823
+ $("auto").addEventListener("change", (event) => {
824
+ if (/** @type {HTMLInputElement} */ (event.target).checked) startPolling();
825
+ else stopPolling();
826
+ });
827
+
828
+ $("refresh").addEventListener("click", () => void load());
829
+
830
+ $("logout").addEventListener("click", async () => {
831
+ stopPolling();
832
+ await fetch("logout", { method: "POST" });
833
+ location.reload();
834
+ });
835
+
836
+ // Arama her tuşta sunucuya gitmez: on binlerce anahtarlı bir önbellekte
837
+ // filtreleme sunucuda yapılıyor.
838
+ let searchTimer = 0;
839
+ $("search").addEventListener("input", (event) => {
840
+ query = /** @type {HTMLInputElement} */ (event.target).value;
841
+ clearTimeout(searchTimer);
842
+ searchTimer = setTimeout(load, 200);
843
+ });
844
+
845
+ for (const name of /** @type {const} */ (["html", "data"])) {
846
+ $(`tab-${name}`).addEventListener("click", () => {
847
+ tab = name;
848
+ $("tab-html").setAttribute("aria-selected", String(name === "html"));
849
+ $("tab-data").setAttribute("aria-selected", String(name === "data"));
850
+ renderTable();
851
+ });
852
+ }
853
+
854
+ $("invalidate").addEventListener("click", () => {
855
+ const target = $("target").value.trim();
856
+ if (!target) {
857
+ toast(t("toast.needTarget"), false);
858
+ return;
859
+ }
860
+
861
+ void act({ type: "html:invalidate", target, hard: $("hard").checked });
862
+ });
863
+
864
+ $("clear-html").addEventListener("click", () => {
865
+ if (!confirm(t("confirm.clearHtml"))) return;
866
+ void act({ type: "html:clear" });
867
+ });
868
+
869
+ $("clear-data").addEventListener("click", () => {
870
+ const prefix = $("prefix").value.trim();
871
+ if (!prefix && !confirm(t("confirm.clearData"))) {
872
+ return;
873
+ }
874
+
875
+ void act({ type: "data:clear", prefix });
876
+ });
877
+
878
+ $("prewarm").addEventListener("click", () => void act({ type: "prewarm" }));
879
+
880
+ // Sayım ayrı bir düğme: her döküm turunda tüm keyspace'i taramak Redis'i
881
+ // teşhis uğruna yormak olurdu.
882
+ $("redis-inspect").addEventListener("click", () => void act({ type: "redis:inspect" }));
883
+
884
+ $("redis-html").addEventListener("click", () =>
885
+ void act({ type: "redis:drop", kind: "html" }),
886
+ );
887
+
888
+ $("redis-data").addEventListener("click", () =>
889
+ void act({ type: "redis:drop", kind: "data" }),
890
+ );
891
+
892
+ /* ------------------------------------------------- cloudflare kontrolleri */
893
+
894
+ $("cf-refresh").addEventListener("click", () => void loadCloudflare(true));
895
+
896
+ $("cf-purge-all").addEventListener("click", () => {
897
+ if (!confirm(t("confirm.purgeAll"))) return;
898
+ void act({ type: "cf:purge-everything" });
899
+ });
900
+
901
+ // Bu kurulumda sıcak olan yollar zaten biliniyor; "her şeyi düşür" yerine
902
+ // yalnızca gerçekten servis edilen sayfaları düşürmek edge'i boşa soğutmaz.
903
+ $("cf-purge-cached").addEventListener("click", () => {
904
+ const paths = (latest?.html.entries ?? []).map((/** @type {any} */ entry) => entry.url);
905
+ if (!paths.length) {
906
+ toast(t("toast.noCachedPages"), false);
907
+ return;
908
+ }
909
+
910
+ if (!confirm(t("confirm.purgeUrls", { count: paths.length }))) return;
911
+ void act({ type: "cf:purge-urls", paths });
912
+ });
913
+
914
+ $("cf-purge-keys").addEventListener("click", () => {
915
+ const values = $("cf-values").value.trim();
916
+ if (!values) {
917
+ toast(t("toast.needValue"), false);
918
+ return;
919
+ }
920
+
921
+ void act({ type: "cf:purge-keys", kind: $("cf-kind").value, values });
922
+ });
923
+
924
+ $("cf-dev").addEventListener("change", (event) => {
925
+ const on = /** @type {HTMLInputElement} */ (event.target).checked;
926
+ void act({ type: "cf:setting", id: "development_mode", value: on ? "on" : "off" });
927
+ });
928
+
929
+ $("cf-tiered").addEventListener("click", () => {
930
+ const current = cloudflare?.settings?.tieredCaching;
931
+ void act({ type: "cf:feature", feature: "tiered_caching", value: flip(current) });
932
+ });
933
+
934
+ $("cf-reserve").addEventListener("click", () => {
935
+ const current = cloudflare?.settings?.cacheReserve;
936
+ void act({ type: "cf:feature", feature: "cache_reserve", value: flip(current) });
937
+ });
938
+
939
+ $("cf-reserve-clear").addEventListener("click", () => {
940
+ if (!confirm(t("confirm.clearReserve"))) return;
941
+ void act({ type: "cf:clear-reserve" });
942
+ });
943
+
944
+ $("cf-analytics").addEventListener("click", () => void loadCloudflareAnalytics());
945
+
946
+ /**
947
+ * @param {string | null | undefined} value
948
+ * @returns {"on" | "off"}
949
+ */
950
+ function flip(value) {
951
+ return value === "on" ? "off" : "on";
952
+ }
953
+
954
+ // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
955
+ // dakikalarca boşa istek atmasın.
956
+ document.addEventListener("visibilitychange", () => {
957
+ if (document.hidden) {
958
+ stopPolling();
959
+ stopLogStream();
960
+ } else if ($("auto").checked) {
961
+ void load();
962
+ startPolling();
963
+ if (page === "logs") startLogStream();
964
+ }
965
+ });
966
+
967
+ /* -------------------------------------------------------------- sayfalar */
968
+
969
+ /**
970
+ * URL yolunun son segmentinden sayfa adı.
971
+ * @returns {string}
972
+ */
973
+ function pageFromLocation() {
974
+ const parts = location.pathname.replace(/\/+$/, "").split("/");
975
+ const last = parts[parts.length - 1] || "";
976
+ if (PAGES.has(last)) return last;
977
+ return "overview";
978
+ }
979
+
980
+ /**
981
+ * @param {string} next
982
+ * @param {{ push?: boolean }} [options]
983
+ */
984
+ function showPage(next, options = {}) {
985
+ const name = PAGES.has(next) ? next : "overview";
986
+ page = name;
987
+
988
+ for (const node of document.querySelectorAll(".page")) {
989
+ const el = /** @type {HTMLElement} */ (node);
990
+ el.hidden = el.dataset.page !== name;
991
+ }
992
+
993
+ for (const link of document.querySelectorAll("#nav a")) {
994
+ const a = /** @type {HTMLAnchorElement} */ (link);
995
+ if (a.dataset.page === name) a.setAttribute("aria-current", "page");
996
+ else a.removeAttribute("aria-current");
997
+ }
998
+
999
+ const sse = $("sse-status");
1000
+ if (sse) sse.hidden = name !== "logs";
1001
+
1002
+ if (options.push !== false) {
1003
+ const target = name === "overview" ? "./" : name;
1004
+ history.pushState({ page: name }, "", target);
1005
+ }
1006
+
1007
+ if (name === "routes") void loadRoutes();
1008
+ if (name === "views") void loadViews();
1009
+ if (name === "logs") startLogStream();
1010
+ else stopLogStream();
1011
+ }
1012
+
1013
+ async function loadRoutes() {
1014
+ const response = await fetch("api/routes");
1015
+ if (!alive(response)) return;
1016
+ if (!response.ok) return;
1017
+ routesPayload = await response.json();
1018
+ renderRoutes();
1019
+ }
1020
+
1021
+ async function loadViews() {
1022
+ const response = await fetch("api/views");
1023
+ if (!alive(response)) return;
1024
+ if (!response.ok) return;
1025
+ viewsPayload = await response.json();
1026
+ renderViews();
1027
+ }
1028
+
1029
+ function renderRoutes() {
1030
+ if (!routesPayload) return;
1031
+
1032
+ const routes = routesPayload.routes ?? [];
1033
+ const modules = routesPayload.modules ?? [];
1034
+ const activity = routesPayload.activity ?? {};
1035
+
1036
+ $("routes-count").textContent = String(routes.length);
1037
+ $("modules-count").textContent = String(modules.length);
1038
+
1039
+ const tbody = $("routes-tbody");
1040
+ const empty = $("routes-empty");
1041
+ tbody.replaceChildren();
1042
+
1043
+ if (!routes.length) {
1044
+ empty.hidden = false;
1045
+ } else {
1046
+ empty.hidden = true;
1047
+ for (const route of routes) {
1048
+ const key = route.path;
1049
+ const act = activity[key] ?? null;
1050
+ const row = document.createElement("tr");
1051
+ row.innerHTML = `
1052
+ <td class="mono">${escapeHtml(route.method)}</td>
1053
+ <td class="mono"><button type="button" class="linkish" data-path="${escapeHtml(route.path)}">${escapeHtml(route.path)}</button></td>
1054
+ <td class="mono">${act ? act.status : "—"}</td>
1055
+ <td class="mono">${act ? Math.round(act.ms) : "—"}</td>
1056
+ <td class="mono">${act ? act.count : "—"}</td>`;
1057
+ tbody.append(row);
1058
+ }
1059
+
1060
+ tbody.querySelectorAll("button[data-path]").forEach((button) => {
1061
+ button.addEventListener("click", () => {
1062
+ const pathFilter = /** @type {HTMLElement} */ (button).dataset.path ?? "";
1063
+ $("logs-path").value = pathFilter;
1064
+ showPage("logs");
1065
+ renderLogs();
1066
+ });
1067
+ });
1068
+ }
1069
+
1070
+ const modulesBody = $("modules-tbody");
1071
+ modulesBody.replaceChildren();
1072
+ for (const mod of modules) {
1073
+ const row = document.createElement("tr");
1074
+ row.innerHTML = `<td class="mono">${escapeHtml(mod.relative)}</td>`;
1075
+ modulesBody.append(row);
1076
+ }
1077
+ }
1078
+
1079
+ function renderViews() {
1080
+ if (!viewsPayload) return;
1081
+
1082
+ const term = ($("views-search")?.value ?? "").trim().toLowerCase();
1083
+ const views = (viewsPayload.views ?? []).filter(
1084
+ (/** @type {{ relative: string }} */ view) =>
1085
+ !term || view.relative.toLowerCase().includes(term),
1086
+ );
1087
+
1088
+ $("views-count").textContent = String(views.length);
1089
+ const tbody = $("views-tbody");
1090
+ const empty = $("views-empty");
1091
+ tbody.replaceChildren();
1092
+
1093
+ if (!views.length) {
1094
+ empty.hidden = false;
1095
+ return;
1096
+ }
1097
+
1098
+ empty.hidden = true;
1099
+ for (const view of views) {
1100
+ const row = document.createElement("tr");
1101
+ row.innerHTML = `<td class="mono">${escapeHtml(view.relative)}</td><td>${escapeHtml(view.kind)}</td>`;
1102
+ tbody.append(row);
1103
+ }
1104
+ }
1105
+
1106
+ function startLogStream() {
1107
+ if (logSource) return;
1108
+
1109
+ const chip = $("sse-status");
1110
+ if (chip) {
1111
+ chip.hidden = false;
1112
+ chip.textContent = t("chip.sse.live");
1113
+ }
1114
+
1115
+ logSource = new EventSource("api/logs/stream");
1116
+ logSource.onmessage = (event) => {
1117
+ try {
1118
+ const entry = JSON.parse(event.data);
1119
+ if (!logsPaused) {
1120
+ logBuffer.push(entry);
1121
+ if (logBuffer.length > 1000) logBuffer.shift();
1122
+ renderLogs();
1123
+ }
1124
+ } catch {
1125
+ // bozuk çerçeve yok sayılır
1126
+ }
1127
+ };
1128
+ logSource.onerror = () => {
1129
+ if (chip) chip.textContent = t("chip.sse.error");
1130
+ };
1131
+ }
1132
+
1133
+ function stopLogStream() {
1134
+ if (logSource) {
1135
+ logSource.close();
1136
+ logSource = null;
1137
+ }
1138
+ const chip = $("sse-status");
1139
+ if (chip) {
1140
+ chip.textContent = t("chip.sse.off");
1141
+ chip.hidden = page !== "logs";
1142
+ }
1143
+ }
1144
+
1145
+ function renderLogs() {
1146
+ const list = $("logs-list");
1147
+ const empty = $("logs-empty");
1148
+ const count = $("logs-count");
1149
+ if (!list || !empty || !count) return;
1150
+
1151
+ const q = ($("logs-q")?.value ?? "").trim().toLowerCase();
1152
+ const kind = $("logs-kind")?.value ?? "";
1153
+ const method = $("logs-method")?.value ?? "";
1154
+ const statusClass = $("logs-status")?.value ?? "";
1155
+ const cache = $("logs-cache")?.value ?? "";
1156
+ const pathFilter = ($("logs-path")?.value ?? "").trim().toLowerCase();
1157
+
1158
+ const filtered = logBuffer.filter((entry) => {
1159
+ if (kind && entry.kind !== kind) return false;
1160
+ if (method && entry.method !== method) return false;
1161
+ if (statusClass && entry.status != null) {
1162
+ if (String(Math.floor(Number(entry.status) / 100)) !== statusClass) return false;
1163
+ }
1164
+ if (cache && entry.cache !== cache) return false;
1165
+ if (pathFilter) {
1166
+ const hay = `${entry.path ?? ""} ${entry.route ?? ""} ${entry.url ?? ""}`.toLowerCase();
1167
+ if (!hay.includes(pathFilter)) return false;
1168
+ }
1169
+ if (q) {
1170
+ const hay = `${entry.url ?? ""} ${entry.message ?? ""} ${entry.scope ?? ""} ${entry.path ?? ""}`.toLowerCase();
1171
+ if (!hay.includes(q)) return false;
1172
+ }
1173
+ return true;
1174
+ });
1175
+
1176
+ count.textContent = t("logs.shown", { shown: filtered.length, total: logBuffer.length });
1177
+ empty.hidden = filtered.length > 0;
1178
+
1179
+ const lines = filtered.slice(-300).map(formatLogLine);
1180
+ list.textContent = lines.join("\n");
1181
+ list.scrollTop = list.scrollHeight;
1182
+ }
1183
+
1184
+ /**
1185
+ * @param {any} entry
1186
+ * @returns {string}
1187
+ */
1188
+ function formatLogLine(entry) {
1189
+ const time = new Date(entry.at).toISOString().slice(11, 19);
1190
+ if (entry.kind === "http") {
1191
+ const cache = entry.cache ? ` ${entry.cache}` : "";
1192
+ const route = entry.route ? ` [${entry.route}]` : "";
1193
+ return `${time} ${String(entry.method).padEnd(6)} ${entry.status} ${Math.round(entry.ms)}ms${cache}${route} ${entry.url}`;
1194
+ }
1195
+ return `${time} ${entry.kind.padEnd(6)} ${entry.scope ?? ""} ${entry.message ?? ""}${entry.note ? ` ${entry.note}` : ""}`;
1196
+ }
1197
+
1198
+ for (const link of document.querySelectorAll("#nav a")) {
1199
+ link.addEventListener("click", (event) => {
1200
+ event.preventDefault();
1201
+ const next = /** @type {HTMLElement} */ (link).dataset.page ?? "overview";
1202
+ showPage(next);
1203
+ });
1204
+ }
1205
+
1206
+ window.addEventListener("popstate", () => {
1207
+ showPage(pageFromLocation(), { push: false });
1208
+ });
1209
+
1210
+ $("views-search")?.addEventListener("input", () => renderViews());
1211
+
1212
+ for (const id of ["logs-q", "logs-kind", "logs-method", "logs-status", "logs-cache", "logs-path"]) {
1213
+ $(id)?.addEventListener("input", () => renderLogs());
1214
+ $(id)?.addEventListener("change", () => renderLogs());
1215
+ }
1216
+
1217
+ $("logs-pause")?.addEventListener("change", (event) => {
1218
+ logsPaused = /** @type {HTMLInputElement} */ (event.target).checked;
1219
+ });
1220
+
1221
+ $("logs-clear")?.addEventListener("click", () => {
1222
+ logBuffer = [];
1223
+ renderLogs();
1224
+ });
1225
+
1226
+ /* -------------------------------------------------------------------- dil */
1227
+
1228
+ // Dil değişimi hiçbir isteğe yol açmaz: statik metinler sözlükten yeniden
1229
+ // yazılır, dinamik olanlar elde duran son dökümle tekrar çizilir.
1230
+ $("language").append(
1231
+ languageSelect(() => {
1232
+ applyTranslations();
1233
+ if (latest) render();
1234
+ renderCloudflare();
1235
+ renderReport();
1236
+ renderRoutes();
1237
+ renderViews();
1238
+ renderLogs();
1239
+ }),
1240
+ );
1241
+
1242
+ applyTranslations();
1243
+
1244
+ const params = new URLSearchParams(location.search);
1245
+ if (params.get("path")) $("logs-path").value = params.get("path") ?? "";
1246
+ if (params.get("route")) $("logs-path").value = params.get("route") ?? "";
1247
+
1248
+ showPage(pageFromLocation(), { push: false });
1249
+ void load();
1250
+ void loadCloudflare();
1251
+ startPolling();