jskelet 0.1.1

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 (72) hide show
  1. package/AGENTS.md +127 -0
  2. package/CHANGELOG.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +342 -0
  5. package/bin/jskelet.mjs +104 -0
  6. package/docs/01-baslangic.md +285 -0
  7. package/docs/02-mimari.md +287 -0
  8. package/docs/03-routing.md +437 -0
  9. package/docs/04-render-ve-sablonlar.md +490 -0
  10. package/docs/05-islands.md +429 -0
  11. package/docs/06-cache.md +409 -0
  12. package/docs/07-yapilandirma.md +673 -0
  13. package/docs/08-build.md +366 -0
  14. package/docs/09-dev-araclari.md +302 -0
  15. package/docs/10-dagitim.md +329 -0
  16. package/docs/11-tasima.md +352 -0
  17. package/docs/README.md +82 -0
  18. package/package.json +97 -0
  19. package/src/build/build.mjs +138 -0
  20. package/src/build/ensure-build.mjs +15 -0
  21. package/src/build/paths.mjs +118 -0
  22. package/src/build/resolve-peer.mjs +36 -0
  23. package/src/build/tasks/client.mjs +268 -0
  24. package/src/build/tasks/css.mjs +124 -0
  25. package/src/build/tasks/fonts.mjs +146 -0
  26. package/src/build/tasks/icons.mjs +224 -0
  27. package/src/build/tasks/images.mjs +244 -0
  28. package/src/build/tasks/precompress.mjs +78 -0
  29. package/src/client/devtools/overlay.js +1763 -0
  30. package/src/client/devtools/report.html +185 -0
  31. package/src/client/devtools/report.js +712 -0
  32. package/src/client/dom.js +95 -0
  33. package/src/client/index.js +26 -0
  34. package/src/client/registry.js +223 -0
  35. package/src/client/safe-image.js +91 -0
  36. package/src/client/store.js +36 -0
  37. package/src/config/defaults.js +102 -0
  38. package/src/config/index.js +433 -0
  39. package/src/config/pattern.js +107 -0
  40. package/src/dev-server.mjs +383 -0
  41. package/src/http/control-flow.js +56 -0
  42. package/src/http/request-cache.js +46 -0
  43. package/src/index.js +35 -0
  44. package/src/init.mjs +220 -0
  45. package/src/log.mjs +332 -0
  46. package/src/logo.png +0 -0
  47. package/src/runtime/alias-hooks.mjs +119 -0
  48. package/src/runtime/register.mjs +4 -0
  49. package/src/server/assets.js +119 -0
  50. package/src/server/create-app.js +167 -0
  51. package/src/server/dev/devtools.js +383 -0
  52. package/src/server/dev/report.js +351 -0
  53. package/src/server/head-hints.js +132 -0
  54. package/src/server/html-cache.js +166 -0
  55. package/src/server/metadata.js +102 -0
  56. package/src/server/middleware/compression.js +205 -0
  57. package/src/server/middleware/dev-gate.js +62 -0
  58. package/src/server/middleware/headers.js +37 -0
  59. package/src/server/middleware/redirects.js +32 -0
  60. package/src/server/middleware/static-precompressed.js +100 -0
  61. package/src/server/middleware/upstream-proxy.js +141 -0
  62. package/src/server/prewarm.js +283 -0
  63. package/src/server/render.js +356 -0
  64. package/src/server/router.js +121 -0
  65. package/src/server/status-page.js +164 -0
  66. package/src/server/upstream-tracking.js +51 -0
  67. package/src/start.mjs +7 -0
  68. package/src/templates/layout.ejs +44 -0
  69. package/src/version.mjs +17 -0
  70. package/src/views/components/loader.js +85 -0
  71. package/src/views/helpers/html.js +102 -0
  72. package/src/views/helpers/tags.js +193 -0
@@ -0,0 +1,712 @@
1
+ /**
2
+ * "Detaylı İnceleme" sayfasının arayüzü.
3
+ *
4
+ * Overlay ile aynı mantık: build'e girmez, dev sunucusu bu dosyayı ham olarak
5
+ * servis eder. Tüm veri tek bir uçtan gelir (`/__jskelet/dev/report/data`),
6
+ * sayfa yalnızca onu görselleştirir.
7
+ */
8
+
9
+ const BASE = "/__jskelet/dev";
10
+
11
+ /** @type {any} */
12
+ let data = null;
13
+ let activeTab = "overview";
14
+ let auto = false;
15
+ /** @type {number | null} */
16
+ let timer = null;
17
+
18
+ /** Tablo sıralamaları: tabloId → { key, dir }. */
19
+ const sorting = new Map();
20
+
21
+ /** Arama kutuları: tabloId → metin. */
22
+ const filters = new Map();
23
+
24
+ /** Chunk analizinde seçili çıktı. */
25
+ let selectedChunk = null;
26
+
27
+ const TABS = [
28
+ { id: "overview", label: "Genel bakış" },
29
+ { id: "pages", label: "Sayfalar" },
30
+ { id: "chunks", label: "Chunk analizi" },
31
+ { id: "assets", label: "Varlıklar" },
32
+ { id: "ssr", label: "SSR & CSR" },
33
+ { id: "api", label: "API istekleri" },
34
+ { id: "errors", label: "Hatalar" },
35
+ ];
36
+
37
+ /* ------------------------------------------------------------- yardımcılar */
38
+
39
+ /** @param {number | null | undefined} bytes */
40
+ function size(bytes) {
41
+ if (bytes == null) return "—";
42
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
43
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
44
+ return `${bytes} B`;
45
+ }
46
+
47
+ /** @param {number | null | undefined} value */
48
+ function ms(value) {
49
+ return value == null ? "—" : `${Math.round(value)} ms`;
50
+ }
51
+
52
+ /**
53
+ * @param {number | null | undefined} value
54
+ * @param {[number, number]} bounds iyi/orta sınırı
55
+ */
56
+ function tone(value, [good, mid]) {
57
+ if (value == null) return "";
58
+ if (value <= good) return "good";
59
+ if (value <= mid) return "mid";
60
+ return "bad";
61
+ }
62
+
63
+ /** @param {string} value */
64
+ function escapeHtml(value) {
65
+ return String(value ?? "").replace(
66
+ /[&<>"]/g,
67
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char],
68
+ );
69
+ }
70
+
71
+ /** @param {string} url */
72
+ function link(url) {
73
+ const text = escapeHtml(url);
74
+ if (!url?.startsWith("/")) return text;
75
+ return `<a href="${text}" target="_blank" rel="noreferrer">${text}</a>`;
76
+ }
77
+
78
+ /**
79
+ * @param {{ label: string, value: string, sub?: string, tone?: string, ratio?: number | null }[]} items
80
+ */
81
+ function metrics(items) {
82
+ return `<div class="grid">${items
83
+ .map((item) => {
84
+ const width = Math.round(Math.min(1, Math.max(0.03, item.ratio ?? 0)) * 100);
85
+ return `<div class="metric">
86
+ <div class="label">${escapeHtml(item.label)}</div>
87
+ <div class="value ${item.tone ?? ""}">${escapeHtml(item.value)}</div>
88
+ ${item.sub ? `<div class="sub">${item.sub}</div>` : ""}
89
+ ${
90
+ item.ratio == null
91
+ ? ""
92
+ : `<div class="bar"><span class="${item.tone ?? ""}" style="width:${width}%"></span></div>`
93
+ }
94
+ </div>`;
95
+ })
96
+ .join("")}</div>`;
97
+ }
98
+
99
+ /**
100
+ * Sıralanabilir, aranabilir tablo.
101
+ *
102
+ * @param {string} id
103
+ * @param {{ key: string, label: string, value?: (row: any) => any, render?: (row: any) => string }[]} columns
104
+ * @param {any[]} rows
105
+ * @param {{ search?: boolean, rowAttrs?: (row: any) => string }} [options]
106
+ */
107
+ function table(id, columns, rows, options = {}) {
108
+ const sort = sorting.get(id) ?? { key: columns[0].key, dir: "desc" };
109
+ const query = (filters.get(id) ?? "").trim().toLowerCase();
110
+
111
+ const valueOf = (row, column) =>
112
+ column.value ? column.value(row) : row[column.key];
113
+
114
+ let visible = rows;
115
+ if (query) {
116
+ visible = rows.filter((row) =>
117
+ columns.some((column) =>
118
+ String(valueOf(row, column) ?? "")
119
+ .toLowerCase()
120
+ .includes(query),
121
+ ),
122
+ );
123
+ }
124
+
125
+ const column = columns.find((item) => item.key === sort.key) ?? columns[0];
126
+ visible = [...visible].sort((a, b) => {
127
+ const left = valueOf(a, column);
128
+ const right = valueOf(b, column);
129
+ const compare =
130
+ typeof left === "number" && typeof right === "number"
131
+ ? left - right
132
+ : String(left ?? "").localeCompare(String(right ?? ""), "tr");
133
+ return sort.dir === "asc" ? compare : -compare;
134
+ });
135
+
136
+ const search = options.search
137
+ ? `<div class="toolbar">
138
+ <input type="search" data-filter="${id}" value="${escapeHtml(filters.get(id) ?? "")}"
139
+ placeholder="Filtrele — ${visible.length}/${rows.length} satır" />
140
+ </div>`
141
+ : "";
142
+
143
+ if (!rows.length) {
144
+ return `${search}<div class="empty">Kayıt yok.</div>`;
145
+ }
146
+
147
+ return `${search}
148
+ <div class="table-wrap">
149
+ <table data-table="${id}">
150
+ <thead><tr>${columns
151
+ .map(
152
+ (item) =>
153
+ `<th data-sort="${item.key}" ${
154
+ item.key === sort.key ? `data-dir="${sort.dir}"` : ""
155
+ }>${escapeHtml(item.label)}</th>`,
156
+ )
157
+ .join("")}</tr></thead>
158
+ <tbody>${visible
159
+ .map(
160
+ (row) =>
161
+ `<tr ${options.rowAttrs?.(row) ?? ""}>${columns
162
+ .map(
163
+ (item) =>
164
+ `<td class="${item.key === columns[0].key ? "path" : ""}">${
165
+ item.render ? item.render(row) : escapeHtml(valueOf(row, item) ?? "—")
166
+ }</td>`,
167
+ )
168
+ .join("")}</tr>`,
169
+ )
170
+ .join("")}</tbody>
171
+ </table>
172
+ </div>`;
173
+ }
174
+
175
+ /* ------------------------------------------------------------- sekmeler */
176
+
177
+ function overview() {
178
+ const pages = data.pages ?? [];
179
+ const measured = pages.filter((page) => page.metrics?.lcp != null);
180
+ const lcps = measured.map((page) => page.metrics.lcp);
181
+ const worst = [...measured].sort((a, b) => b.metrics.lcp - a.metrics.lcp).slice(0, 8);
182
+
183
+ const average = lcps.length ? lcps.reduce((sum, value) => sum + value, 0) / lcps.length : null;
184
+ const js = (data.build?.outputs ?? []).filter((item) => item.file.endsWith(".js"));
185
+ const jsBytes = js.reduce((sum, item) => sum + item.bytes, 0);
186
+ const css = (data.build?.assets ?? []).filter((item) => item.kind === "css");
187
+ const cssBytes = css.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
188
+ const ssrBytes = pages.reduce((sum, page) => sum + (page.html?.bytes ?? 0), 0);
189
+ const apiErrors = (data.serverApi ?? []).filter((call) => call.error).length;
190
+ const islandTotal = pages.reduce((sum, page) => sum + (page.islands?.total ?? 0), 0);
191
+ const islandReady = pages.reduce((sum, page) => sum + (page.islands?.ready ?? 0), 0);
192
+
193
+ return `
194
+ <h2>Site geneli<span class="note">${pages.length} sayfa biliniyor, ${measured.length} tanesi tarayıcıda ölçüldü</span></h2>
195
+ ${metrics([
196
+ {
197
+ label: "Ortalama LCP",
198
+ value: ms(average),
199
+ tone: tone(average, [2500, 4000]),
200
+ ratio: average == null ? null : average / 4000,
201
+ sub: `en kötü ${ms(worst[0]?.metrics?.lcp)}`,
202
+ },
203
+ {
204
+ label: "Ölçülen sayfa",
205
+ value: `${measured.length}/${pages.length}`,
206
+ ratio: pages.length ? measured.length / pages.length : null,
207
+ tone: "good",
208
+ sub: "gez ki ölçülsün",
209
+ },
210
+ { label: "JS çıktısı", value: size(jsBytes), sub: `${js.length} dosya` },
211
+ { label: "CSS çıktısı", value: size(cssBytes), sub: `${css.length} dosya` },
212
+ { label: "SSR HTML", value: size(ssrBytes), sub: "bilinen sayfaların toplamı" },
213
+ {
214
+ label: "Island (CSR)",
215
+ value: `${islandReady}/${islandTotal}`,
216
+ ratio: islandTotal ? islandReady / islandTotal : null,
217
+ tone: "good",
218
+ sub: "bağlanan / işaretlenen",
219
+ },
220
+ {
221
+ label: "HTML önbelleği",
222
+ value: String(data.cache?.size ?? 0),
223
+ sub: `${size((data.cache?.entries ?? []).reduce((sum, item) => sum + item.bytes, 0))} bellekte`,
224
+ },
225
+ {
226
+ label: "Sunucu API çağrısı",
227
+ value: String((data.serverApi ?? []).length),
228
+ tone: apiErrors ? "bad" : "good",
229
+ sub: apiErrors ? `${apiErrors} hata` : "hata yok",
230
+ },
231
+ {
232
+ label: "Prewarm",
233
+ value: data.prewarm?.total
234
+ ? `${data.prewarm.ok}/${data.prewarm.total}`
235
+ : "—",
236
+ ratio: data.prewarm?.total ? data.prewarm.done / data.prewarm.total : null,
237
+ tone: data.prewarm?.failed ? "mid" : "good",
238
+ },
239
+ {
240
+ label: "Süreç",
241
+ value: size(data.process?.memory?.rss),
242
+ sub: `${data.process?.node} · ${Math.round(data.process?.uptime ?? 0)} sn`,
243
+ },
244
+ ])}
245
+
246
+ <h2>En yavaş sayfalar<span class="note">LCP'ye göre</span></h2>
247
+ ${
248
+ worst.length
249
+ ? table(
250
+ "worst",
251
+ [
252
+ { key: "url", label: "Sayfa", render: (row) => link(row.url) },
253
+ { key: "lcp", label: "LCP", value: (row) => row.metrics.lcp, render: (row) => `<span class="${tone(row.metrics.lcp, [2500, 4000])}">${ms(row.metrics.lcp)}</span>` },
254
+ { key: "cls", label: "CLS", value: (row) => row.metrics.cls ?? 0, render: (row) => (row.metrics.cls ?? 0).toFixed(3) },
255
+ { key: "inp", label: "INP", value: (row) => row.metrics.inp ?? 0, render: (row) => ms(row.metrics.inp) },
256
+ { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
257
+ { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
258
+ ],
259
+ worst,
260
+ )
261
+ : `<div class="empty">Henüz tarayıcı ölçümü yok. Sitede gezindikçe sayfalar buraya düşer.</div>`
262
+ }
263
+
264
+ <h2>Web Vitals dağılımı</h2>
265
+ <div class="card">
266
+ ${["lcp", "fcp", "ttfb", "inp"]
267
+ .map((key) => {
268
+ const values = measured
269
+ .map((page) => page.metrics[key])
270
+ .filter((value) => value != null)
271
+ .sort((a, b) => a - b);
272
+ if (!values.length) return "";
273
+ const p = (ratio) => values[Math.min(values.length - 1, Math.floor(values.length * ratio))];
274
+ const bounds = { lcp: [2500, 4000], fcp: [1800, 3000], ttfb: [800, 1800], inp: [200, 500] }[key];
275
+ return `<div class="barrow">
276
+ <span class="name">${key.toUpperCase()} — medyan ${ms(p(0.5))}, p75 ${ms(p(0.75))}, p95 ${ms(p(0.95))}</span>
277
+ <span class="size ${tone(p(0.75), bounds)}">${ms(p(0.75))}</span>
278
+ <span class="track"><span class="${tone(p(0.75), bounds)}" style="width:${Math.min(100, (p(0.75) / (bounds[1] * 1.5)) * 100)}%"></span></span>
279
+ </div>`;
280
+ })
281
+ .join("")}
282
+ </div>
283
+ `;
284
+ }
285
+
286
+ function pagesTab() {
287
+ const rows = data.pages ?? [];
288
+
289
+ return `
290
+ <h2>Sayfalar<span class="note">tarayıcı ölçümleri gezdikçe birikir; SSR sütunları ısıtma turundan gelir</span></h2>
291
+ ${table(
292
+ "pages",
293
+ [
294
+ { key: "url", label: "Sayfa", render: (row) => link(row.url) },
295
+ { key: "visits", label: "Ziyaret", value: (row) => row.visits },
296
+ { key: "lcp", label: "LCP", value: (row) => row.metrics?.lcp ?? -1, render: (row) => `<span class="${tone(row.metrics?.lcp, [2500, 4000])}">${ms(row.metrics?.lcp)}</span>` },
297
+ { key: "cls", label: "CLS", value: (row) => row.metrics?.cls ?? -1, render: (row) => (row.metrics?.cls == null ? "—" : `<span class="${tone(row.metrics.cls * 1000, [100, 250])}">${row.metrics.cls.toFixed(3)}</span>`) },
298
+ { key: "inp", label: "INP", value: (row) => row.metrics?.inp ?? -1, render: (row) => `<span class="${tone(row.metrics?.inp, [200, 500])}">${ms(row.metrics?.inp)}</span>` },
299
+ { key: "fcp", label: "FCP", value: (row) => row.metrics?.fcp ?? -1, render: (row) => ms(row.metrics?.fcp) },
300
+ { key: "ttfb", label: "TTFB", value: (row) => row.metrics?.ttfb ?? -1, render: (row) => `<span class="${tone(row.metrics?.ttfb, [800, 1800])}">${ms(row.metrics?.ttfb)}</span>` },
301
+ { key: "blocking", label: "Blocking", value: (row) => row.metrics?.blocking ?? -1, render: (row) => ms(row.metrics?.blocking) },
302
+ { key: "requests", label: "İstek", value: (row) => row.resources?.count ?? 0 },
303
+ { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
304
+ { key: "islands", label: "Island", value: (row) => row.islands?.total ?? 0, render: (row) => (row.islands?.total ? `${row.islands.ready}/${row.islands.total}` : "—") },
305
+ { key: "api", label: "API", value: (row) => row.api?.length ?? 0 },
306
+ { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
307
+ { key: "render", label: "Render", value: (row) => row.html?.ms ?? -1, render: (row) => ms(row.html?.ms) },
308
+ { key: "cache", label: "Cache", render: (row) => (row.html?.cache ? `<span class="tag">${escapeHtml(row.html.cache)}</span>` : "—") },
309
+ ],
310
+ rows,
311
+ { search: true },
312
+ )}
313
+ `;
314
+ }
315
+
316
+ function chunksTab() {
317
+ const build = data.build ?? {};
318
+ if (!build.available) {
319
+ return `<div class="empty">Chunk analizi için <code>build/generated/metafile.json</code> gerekiyor. <code>npm run dev</code> veya <code>npm run build</code> bir tur döndüğünde oluşur.</div>`;
320
+ }
321
+
322
+ const outputs = build.outputs ?? [];
323
+ const selected =
324
+ outputs.find((item) => item.file === selectedChunk) ?? outputs[0];
325
+ const heaviest = outputs[0]?.bytes ?? 1;
326
+ const totalBytes = outputs.reduce((sum, item) => sum + item.bytes, 0);
327
+ const groupTotal = (build.groups ?? []).reduce((sum, item) => sum + item.bytes, 0) || 1;
328
+
329
+ return `
330
+ <h2>Çıktılar<span class="note">${outputs.length} dosya · ${size(totalBytes)} ham</span></h2>
331
+ <div class="split">
332
+ <div>
333
+ ${table(
334
+ "chunks",
335
+ [
336
+ {
337
+ key: "file",
338
+ label: "Dosya",
339
+ render: (row) =>
340
+ `${link(row.file)} <span class="tag ${row.isChunk ? "chunk" : "entry"}">${row.isChunk ? "chunk" : "entry"}</span>`,
341
+ },
342
+ { key: "bytes", label: "Ham", value: (row) => row.bytes, render: (row) => size(row.bytes) },
343
+ { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
344
+ { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
345
+ { key: "inputCount", label: "Modül", value: (row) => row.inputCount },
346
+ { key: "imports", label: "Import", value: (row) => row.imports.length },
347
+ {
348
+ key: "share",
349
+ label: "Pay",
350
+ value: (row) => row.bytes / heaviest,
351
+ render: (row) => `${Math.round((row.bytes / totalBytes) * 100)}%`,
352
+ },
353
+ ],
354
+ outputs,
355
+ {
356
+ search: true,
357
+ rowAttrs: (row) =>
358
+ `class="clickable ${row.file === selected?.file ? "selected" : ""}" data-chunk="${escapeHtml(row.file)}"`,
359
+ },
360
+ )}
361
+ </div>
362
+ <div>
363
+ <div class="card">
364
+ <h2 style="margin-top:0">Seçili çıktı</h2>
365
+ ${
366
+ selected
367
+ ? `<div class="hint" style="margin-bottom:10px">
368
+ <code>${escapeHtml(selected.file)}</code><br />
369
+ ${size(selected.bytes)} ham · ${size(selected.gzip)} gzip · ${selected.inputCount} modül
370
+ ${selected.entry ? `<br />entry: <code>${escapeHtml(selected.entry)}</code>` : ""}
371
+ </div>
372
+ <div class="bars">
373
+ ${selected.inputs
374
+ .map(
375
+ (input) => `<div class="barrow">
376
+ <span class="name" title="${escapeHtml(input.source)}">${escapeHtml(input.source)}</span>
377
+ <span class="size">${size(input.bytes)}</span>
378
+ <span class="track"><span style="width:${Math.round((input.bytes / selected.inputs[0].bytes) * 100)}%"></span></span>
379
+ </div>`,
380
+ )
381
+ .join("")}
382
+ </div>
383
+ ${
384
+ selected.imports.length
385
+ ? `<h2>Import ettiği chunk'lar</h2>
386
+ <div class="hint">${selected.imports
387
+ .map((item) => `<div><code>${escapeHtml(item.path)}</code> <span class="tag">${escapeHtml(item.kind)}</span></div>`)
388
+ .join("")}</div>`
389
+ : ""
390
+ }`
391
+ : `<div class="hint">Soldan bir çıktı seç.</div>`
392
+ }
393
+ </div>
394
+ </div>
395
+ </div>
396
+
397
+ <h2>Kaynak grupları<span class="note">tüm çıktılarda paket / klasör payı</span></h2>
398
+ <div class="card bars">
399
+ ${(build.groups ?? [])
400
+ .slice(0, 25)
401
+ .map(
402
+ (group) => `<div class="barrow">
403
+ <span class="name">${escapeHtml(group.name)}</span>
404
+ <span class="size">${size(group.bytes)}</span>
405
+ <span class="track"><span style="width:${Math.round((group.bytes / groupTotal) * 100)}%"></span></span>
406
+ </div>`,
407
+ )
408
+ .join("")}
409
+ </div>
410
+ `;
411
+ }
412
+
413
+ function assetsTab() {
414
+ const assets = data.build?.assets ?? [];
415
+ const total = assets.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
416
+
417
+ return `
418
+ <h2>Manifest varlıkları<span class="note">${assets.length} girdi · ${size(total)}</span></h2>
419
+ ${table(
420
+ "assets",
421
+ [
422
+ { key: "name", label: "Ad" },
423
+ { key: "url", label: "Yol", render: (row) => link(row.url) },
424
+ { key: "kind", label: "Tür", render: (row) => `<span class="tag">${escapeHtml(row.kind)}</span>` },
425
+ { key: "bytes", label: "Ham", value: (row) => row.bytes ?? 0, render: (row) => size(row.bytes) },
426
+ { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
427
+ { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
428
+ {
429
+ key: "ratio",
430
+ label: "Kazanç",
431
+ value: (row) => (row.bytes && row.gzip ? 1 - row.gzip / row.bytes : 0),
432
+ render: (row) =>
433
+ row.bytes && row.gzip
434
+ ? `${Math.round((1 - row.gzip / row.bytes) * 100)}%`
435
+ : "—",
436
+ },
437
+ ],
438
+ assets,
439
+ { search: true },
440
+ )}
441
+ `;
442
+ }
443
+
444
+ function ssrTab() {
445
+ const pages = data.pages ?? [];
446
+ const cache = data.cache?.entries ?? [];
447
+
448
+ /** @type {Map<string, { name: string, pages: number }>} */
449
+ const islands = new Map();
450
+ for (const page of pages) {
451
+ for (const name of page.islands?.names ?? []) {
452
+ const entry = islands.get(name) ?? { name, pages: 0 };
453
+ entry.pages += 1;
454
+ islands.set(name, entry);
455
+ }
456
+ }
457
+
458
+ const csr = pages.filter((page) => (page.islands?.total ?? 0) > 0);
459
+ const partial = csr.filter((page) => page.islands.ready < page.islands.total);
460
+
461
+ return `
462
+ <h2>Sunucu render & istemci hidrasyonu</h2>
463
+ ${metrics([
464
+ { label: "SSR sayfa", value: String(pages.filter((page) => page.html?.bytes).length), sub: "HTML boyutu bilinen" },
465
+ { label: "Island'lı sayfa", value: `${csr.length}/${pages.length}`, ratio: pages.length ? csr.length / pages.length : null, tone: "good" },
466
+ { label: "Eksik hidrasyon", value: String(partial.length), tone: partial.length ? "mid" : "good", sub: "bazı island'lar bağlanmadı" },
467
+ { label: "Farklı island", value: String(islands.size) },
468
+ { label: "Önbellek girdisi", value: String(data.cache?.size ?? 0) },
469
+ { label: "Önbellek boyutu", value: size(cache.reduce((sum, item) => sum + item.bytes, 0)) },
470
+ ])}
471
+
472
+ <h2>Island envanteri<span class="note">gezilen sayfalarda görülen island'lar</span></h2>
473
+ ${table(
474
+ "islands",
475
+ [
476
+ { key: "name", label: "Island" },
477
+ { key: "pages", label: "Sayfa sayısı", value: (row) => row.pages },
478
+ ],
479
+ [...islands.values()],
480
+ { search: true },
481
+ )}
482
+
483
+ <h2>HTML önbelleği<span class="note">bellekteki girdiler, bayatlama süresiyle</span></h2>
484
+ ${table(
485
+ "cache",
486
+ [
487
+ { key: "key", label: "Anahtar" },
488
+ { key: "status", label: "Durum", render: (row) => `<span class="status ${row.status >= 400 ? "bad" : ""}">${row.status}</span>` },
489
+ { key: "bytes", label: "HTML", value: (row) => row.bytes, render: (row) => size(row.bytes) },
490
+ { key: "expiresIn", label: "Tazelik", value: (row) => row.expiresIn, render: (row) => (row.stale ? `<span class="mid">bayat</span>` : `${row.expiresIn} sn`) },
491
+ { key: "encodings", label: "Sıkıştırma", render: (row) => (row.encodings.length ? row.encodings.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join(" ") : "—") },
492
+ ],
493
+ cache,
494
+ { search: true },
495
+ )}
496
+ `;
497
+ }
498
+
499
+ function apiTab() {
500
+ const server = data.serverApi ?? [];
501
+
502
+ /** @type {Map<string, { key: string, host: string, calls: number, ms: number, errors: number, bytes: number }>} */
503
+ const grouped = new Map();
504
+ for (const call of server) {
505
+ let route = call.url;
506
+ try {
507
+ route = new URL(call.url).pathname;
508
+ } catch {
509
+ // Göreli adres.
510
+ }
511
+ const key = `${call.host}${route}`;
512
+ const entry = grouped.get(key) ?? { key: route, host: call.host, calls: 0, ms: 0, errors: 0, bytes: 0 };
513
+ entry.calls += 1;
514
+ entry.ms += call.ms;
515
+ entry.errors += call.error ? 1 : 0;
516
+ entry.bytes += call.bytes;
517
+ grouped.set(key, entry);
518
+ }
519
+
520
+ const client = (data.pages ?? []).flatMap((page) =>
521
+ (page.api ?? []).map((call) => ({ ...call, page: page.url })),
522
+ );
523
+
524
+ return `
525
+ <h2>Sunucu tarafı çağrılar<span class="note">SSR sırasında yapılan dış istekler, yola göre gruplu</span></h2>
526
+ ${table(
527
+ "api-groups",
528
+ [
529
+ { key: "key", label: "Yol" },
530
+ { key: "host", label: "Host" },
531
+ { key: "calls", label: "Çağrı", value: (row) => row.calls },
532
+ { key: "avg", label: "Ort. süre", value: (row) => row.ms / row.calls, render: (row) => `<span class="${tone(row.ms / row.calls, [200, 800])}">${ms(row.ms / row.calls)}</span>` },
533
+ { key: "total", label: "Toplam süre", value: (row) => row.ms, render: (row) => ms(row.ms) },
534
+ { key: "bytes", label: "Gövde", value: (row) => row.bytes, render: (row) => size(row.bytes) },
535
+ { key: "errors", label: "Hata", value: (row) => row.errors, render: (row) => (row.errors ? `<span class="bad">${row.errors}</span>` : "0") },
536
+ ],
537
+ [...grouped.values()],
538
+ { search: true },
539
+ )}
540
+
541
+ <h2>Son çağrılar</h2>
542
+ ${table(
543
+ "api-calls",
544
+ [
545
+ { key: "url", label: "URL" },
546
+ { key: "method", label: "Metot" },
547
+ { key: "status", label: "Durum", render: (row) => `<span class="status ${row.error ? "bad" : ""}">${row.status || "—"}</span>` },
548
+ { key: "ms", label: "Süre", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
549
+ { key: "bytes", label: "Gövde", value: (row) => row.bytes, render: (row) => size(row.bytes) },
550
+ { key: "error", label: "Hata", render: (row) => (row.error ? `<span class="bad">${escapeHtml(row.error)}</span>` : "—") },
551
+ ],
552
+ server.slice(0, 200),
553
+ { search: true },
554
+ )}
555
+
556
+ <h2>Tarayıcı tarafı çağrılar<span class="note">island'ların yaptığı fetch istekleri</span></h2>
557
+ ${table(
558
+ "api-client",
559
+ [
560
+ { key: "url", label: "URL" },
561
+ { key: "page", label: "Sayfa", render: (row) => link(row.page) },
562
+ { key: "status", label: "Durum", render: (row) => `<span class="status ${row.status >= 400 || !row.status ? "bad" : ""}">${row.status || "—"}</span>` },
563
+ { key: "ms", label: "Süre", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
564
+ { key: "bytes", label: "Gövde", value: (row) => row.bytes, render: (row) => size(row.bytes) },
565
+ ],
566
+ client,
567
+ { search: true },
568
+ )}
569
+ `;
570
+ }
571
+
572
+ function errorsTab() {
573
+ return `
574
+ <h2>Hatalar<span class="note">sunucu günlüğü</span></h2>
575
+ ${table(
576
+ "errors",
577
+ [
578
+ { key: "message", label: "Mesaj" },
579
+ { key: "level", label: "Seviye", render: (row) => `<span class="tag">${escapeHtml(row.level)}</span>` },
580
+ { key: "at", label: "Zaman", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString("tr-TR") },
581
+ ],
582
+ data.errors ?? [],
583
+ { search: true },
584
+ )}
585
+
586
+ <h2>Son istekler</h2>
587
+ ${table(
588
+ "requests",
589
+ [
590
+ { key: "url", label: "URL", render: (row) => link(row.url) },
591
+ { key: "status", label: "Durum", render: (row) => `<span class="status ${row.status >= 500 ? "bad" : row.status >= 400 ? "warn" : ""}">${row.status}</span>` },
592
+ { key: "ms", label: "Süre", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [150, 500])}">${ms(row.ms)}</span>` },
593
+ { key: "cache", label: "Cache", render: (row) => (row.cache ? `<span class="tag">${escapeHtml(row.cache)}</span>` : "—") },
594
+ { key: "at", label: "Zaman", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString("tr-TR") },
595
+ ],
596
+ data.requests ?? [],
597
+ { search: true },
598
+ )}
599
+ `;
600
+ }
601
+
602
+ const RENDERERS = {
603
+ overview,
604
+ pages: pagesTab,
605
+ chunks: chunksTab,
606
+ assets: assetsTab,
607
+ ssr: ssrTab,
608
+ api: apiTab,
609
+ errors: errorsTab,
610
+ };
611
+
612
+ /* ------------------------------------------------------------------ çizim */
613
+
614
+ function render() {
615
+ const nav = document.querySelector("[data-part='tabs']");
616
+ nav.innerHTML = TABS.map(
617
+ (tab) =>
618
+ `<button data-tab="${tab.id}" aria-selected="${tab.id === activeTab}">${tab.label}</button>`,
619
+ ).join("");
620
+
621
+ const main = document.querySelector("[data-part='main']");
622
+ main.innerHTML = data
623
+ ? RENDERERS[activeTab]()
624
+ : `<div class="empty">Veri yükleniyor…</div>`;
625
+
626
+ const status = document.querySelector("[data-part='status']");
627
+ if (data) {
628
+ const time = new Date(data.generatedAt).toLocaleTimeString("tr-TR");
629
+ status.textContent = `${data.pages.length} sayfa · ${time}`;
630
+ }
631
+
632
+ document.querySelector("[data-action='auto']").setAttribute("aria-pressed", String(auto));
633
+ }
634
+
635
+ async function load() {
636
+ try {
637
+ const response = await fetch(`${BASE}/report/data`, { cache: "no-store" });
638
+ data = await response.json();
639
+ } catch {
640
+ document.querySelector("[data-part='status']").textContent = "sunucuya ulaşılamıyor";
641
+ return;
642
+ }
643
+ render();
644
+ }
645
+
646
+ /* ------------------------------------------------------------ etkileşim */
647
+
648
+ document.addEventListener("click", (event) => {
649
+ const target = event.target.closest("[data-tab], [data-sort], [data-chunk], [data-action]");
650
+ if (!target) return;
651
+
652
+ if (target.dataset.action === "refresh") {
653
+ load();
654
+ return;
655
+ }
656
+
657
+ if (target.dataset.action === "auto") {
658
+ auto = !auto;
659
+ clearInterval(timer);
660
+ if (auto) timer = setInterval(load, 5000);
661
+ render();
662
+ return;
663
+ }
664
+
665
+ if (target.dataset.action === "reset") {
666
+ fetch(`${BASE}/report/clear`, { method: "POST" }).finally(load);
667
+ return;
668
+ }
669
+
670
+ if (target.dataset.tab) {
671
+ activeTab = target.dataset.tab;
672
+ location.hash = activeTab;
673
+ render();
674
+ return;
675
+ }
676
+
677
+ if (target.dataset.sort) {
678
+ const id = target.closest("table").dataset.table;
679
+ const current = sorting.get(id);
680
+ sorting.set(id, {
681
+ key: target.dataset.sort,
682
+ dir: current?.key === target.dataset.sort && current.dir === "desc" ? "asc" : "desc",
683
+ });
684
+ render();
685
+ return;
686
+ }
687
+
688
+ if (target.dataset.chunk) {
689
+ selectedChunk = target.dataset.chunk;
690
+ render();
691
+ }
692
+ });
693
+
694
+ document.addEventListener("input", (event) => {
695
+ const field = event.target.closest("[data-filter]");
696
+ if (!field) return;
697
+
698
+ filters.set(field.dataset.filter, field.value);
699
+ render();
700
+
701
+ // Yeniden çizim odağı düşürür; yazmaya devam edilebilsin.
702
+ const next = document.querySelector(`[data-filter="${field.dataset.filter}"]`);
703
+ next?.focus();
704
+ next?.setSelectionRange(field.value.length, field.value.length);
705
+ });
706
+
707
+ if (TABS.some((tab) => tab.id === location.hash.slice(1))) {
708
+ activeTab = location.hash.slice(1);
709
+ }
710
+
711
+ render();
712
+ load();