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,745 +1,745 @@
1
- /**
2
- * "Inspect in detail" 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: "Overview" },
29
- { id: "pages", label: "Pages" },
30
- { id: "chunks", label: "Chunk analysis" },
31
- { id: "assets", label: "Assets" },
32
- { id: "ssr", label: "SSR & CSR" },
33
- { id: "api", label: "API requests" },
34
- { id: "errors", label: "Errors" },
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 ?? ""));
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="Filter — ${visible.length}/${rows.length} rows" />
140
- </div>`
141
- : "";
142
-
143
- if (!rows.length) {
144
- return `${search}<div class="empty">No records.</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 wide<span class="note">${pages.length} pages known, ${measured.length} of them measured in the browser</span></h2>
195
- ${metrics([
196
- {
197
- label: "Average LCP",
198
- value: ms(average),
199
- tone: tone(average, [2500, 4000]),
200
- ratio: average == null ? null : average / 4000,
201
- sub: `worst ${ms(worst[0]?.metrics?.lcp)}`,
202
- },
203
- {
204
- label: "Measured pages",
205
- value: `${measured.length}/${pages.length}`,
206
- ratio: pages.length ? measured.length / pages.length : null,
207
- tone: "good",
208
- sub: "browse to measure",
209
- },
210
- { label: "JS output", value: size(jsBytes), sub: `${js.length} files` },
211
- { label: "CSS output", value: size(cssBytes), sub: `${css.length} files` },
212
- { label: "SSR HTML", value: size(ssrBytes), sub: "total of known pages" },
213
- {
214
- label: "Island (CSR)",
215
- value: `${islandReady}/${islandTotal}`,
216
- ratio: islandTotal ? islandReady / islandTotal : null,
217
- tone: "good",
218
- sub: "mounted / marked",
219
- },
220
- {
221
- label: "HTML cache",
222
- value: String(data.cache?.size ?? 0),
223
- sub: `${size((data.cache?.entries ?? []).reduce((sum, item) => sum + item.bytes, 0))} in memory`,
224
- },
225
- {
226
- label: "Server API calls",
227
- value: String((data.serverApi ?? []).length),
228
- tone: apiErrors ? "bad" : "good",
229
- sub: apiErrors ? `${apiErrors} errors` : "no errors",
230
- },
231
- // Upstream kotasının nasıl harcandığı: okumaların kaçı önbellekten
232
- // döndü. Oran düşükse `withDataCache` TTL'i kısa demektir.
233
- {
234
- label: "Data cache",
235
- value: data.cache?.dataStats?.reads
236
- ? `${Math.round(data.cache.dataStats.hitRatio * 100)}%`
237
- : "—",
238
- ratio: data.cache?.dataStats?.hitRatio ?? null,
239
- tone: "good",
240
- sub: data.cache?.dataStats?.reads
241
- ? `${data.cache.dataStats.produced} upstream / ${data.cache.dataStats.reads} reads`
242
- : "no reads yet",
243
- },
244
- {
245
- label: "Prewarm",
246
- value: data.prewarm?.total
247
- ? `${data.prewarm.ok}/${data.prewarm.total}`
248
- : "—",
249
- ratio: data.prewarm?.total ? data.prewarm.done / data.prewarm.total : null,
250
- tone: data.prewarm?.failed ? "mid" : "good",
251
- },
252
- {
253
- label: "Process",
254
- value: size(data.process?.memory?.rss),
255
- sub: `${data.process?.node} · ${Math.round(data.process?.uptime ?? 0)} s`,
256
- },
257
- ])}
258
-
259
- <h2>Slowest pages<span class="note">by LCP</span></h2>
260
- ${
261
- worst.length
262
- ? table(
263
- "worst",
264
- [
265
- { key: "url", label: "Page", render: (row) => link(row.url) },
266
- { key: "lcp", label: "LCP", value: (row) => row.metrics.lcp, render: (row) => `<span class="${tone(row.metrics.lcp, [2500, 4000])}">${ms(row.metrics.lcp)}</span>` },
267
- { key: "cls", label: "CLS", value: (row) => row.metrics.cls ?? 0, render: (row) => (row.metrics.cls ?? 0).toFixed(3) },
268
- { key: "inp", label: "INP", value: (row) => row.metrics.inp ?? 0, render: (row) => ms(row.metrics.inp) },
269
- { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
270
- { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
271
- ],
272
- worst,
273
- )
274
- : `<div class="empty">No browser measurements yet. Pages land here as you browse the site.</div>`
275
- }
276
-
277
- <h2>Web Vitals distribution</h2>
278
- <div class="card">
279
- ${["lcp", "fcp", "ttfb", "inp"]
280
- .map((key) => {
281
- const values = measured
282
- .map((page) => page.metrics[key])
283
- .filter((value) => value != null)
284
- .sort((a, b) => a - b);
285
- if (!values.length) return "";
286
- const p = (ratio) => values[Math.min(values.length - 1, Math.floor(values.length * ratio))];
287
- const bounds = { lcp: [2500, 4000], fcp: [1800, 3000], ttfb: [800, 1800], inp: [200, 500] }[key];
288
- return `<div class="barrow">
289
- <span class="name">${key.toUpperCase()} — median ${ms(p(0.5))}, p75 ${ms(p(0.75))}, p95 ${ms(p(0.95))}</span>
290
- <span class="size ${tone(p(0.75), bounds)}">${ms(p(0.75))}</span>
291
- <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>
292
- </div>`;
293
- })
294
- .join("")}
295
- </div>
296
- `;
297
- }
298
-
299
- function pagesTab() {
300
- const rows = data.pages ?? [];
301
-
302
- return `
303
- <h2>Pages<span class="note">browser measurements build up as you browse; SSR columns come from the prewarm round</span></h2>
304
- ${table(
305
- "pages",
306
- [
307
- { key: "url", label: "Page", render: (row) => link(row.url) },
308
- { key: "visits", label: "Visits", value: (row) => row.visits },
309
- { 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>` },
310
- { 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>`) },
311
- { 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>` },
312
- { key: "fcp", label: "FCP", value: (row) => row.metrics?.fcp ?? -1, render: (row) => ms(row.metrics?.fcp) },
313
- { 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>` },
314
- { key: "blocking", label: "Blocking", value: (row) => row.metrics?.blocking ?? -1, render: (row) => ms(row.metrics?.blocking) },
315
- { key: "requests", label: "Requests", value: (row) => row.resources?.count ?? 0 },
316
- { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
317
- { key: "islands", label: "Island", value: (row) => row.islands?.total ?? 0, render: (row) => (row.islands?.total ? `${row.islands.ready}/${row.islands.total}` : "—") },
318
- { key: "api", label: "API", value: (row) => row.api?.length ?? 0 },
319
- { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
320
- { key: "render", label: "Render", value: (row) => row.html?.ms ?? -1, render: (row) => ms(row.html?.ms) },
321
- { key: "cache", label: "Cache", render: (row) => (row.html?.cache ? `<span class="tag">${escapeHtml(row.html.cache)}</span>` : "—") },
322
- ],
323
- rows,
324
- { search: true },
325
- )}
326
- `;
327
- }
328
-
329
- function chunksTab() {
330
- const build = data.build ?? {};
331
- if (!build.available) {
332
- return `<div class="empty">Chunk analysis needs <code>build/generated/metafile.json</code>. It appears once <code>npm run dev</code> or <code>npm run build</code> has completed a round.</div>`;
333
- }
334
-
335
- const outputs = build.outputs ?? [];
336
- const selected =
337
- outputs.find((item) => item.file === selectedChunk) ?? outputs[0];
338
- const heaviest = outputs[0]?.bytes ?? 1;
339
- const totalBytes = outputs.reduce((sum, item) => sum + item.bytes, 0);
340
- const groupTotal = (build.groups ?? []).reduce((sum, item) => sum + item.bytes, 0) || 1;
341
-
342
- return `
343
- <h2>Outputs<span class="note">${outputs.length} files · ${size(totalBytes)} raw</span></h2>
344
- <div class="split">
345
- <div>
346
- ${table(
347
- "chunks",
348
- [
349
- {
350
- key: "file",
351
- label: "File",
352
- render: (row) =>
353
- `${link(row.file)} <span class="tag ${row.isChunk ? "chunk" : "entry"}">${row.isChunk ? "chunk" : "entry"}</span>`,
354
- },
355
- { key: "bytes", label: "Raw", value: (row) => row.bytes, render: (row) => size(row.bytes) },
356
- { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
357
- { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
358
- { key: "inputCount", label: "Modules", value: (row) => row.inputCount },
359
- { key: "imports", label: "Imports", value: (row) => row.imports.length },
360
- {
361
- key: "share",
362
- label: "Share",
363
- value: (row) => row.bytes / heaviest,
364
- render: (row) => `${Math.round((row.bytes / totalBytes) * 100)}%`,
365
- },
366
- ],
367
- outputs,
368
- {
369
- search: true,
370
- rowAttrs: (row) =>
371
- `class="clickable ${row.file === selected?.file ? "selected" : ""}" data-chunk="${escapeHtml(row.file)}"`,
372
- },
373
- )}
374
- </div>
375
- <div>
376
- <div class="card">
377
- <h2 style="margin-top:0">Selected output</h2>
378
- ${
379
- selected
380
- ? `<div class="hint" style="margin-bottom:10px">
381
- <code>${escapeHtml(selected.file)}</code><br />
382
- ${size(selected.bytes)} raw · ${size(selected.gzip)} gzip · ${selected.inputCount} modules
383
- ${selected.entry ? `<br />entry: <code>${escapeHtml(selected.entry)}</code>` : ""}
384
- </div>
385
- <div class="bars">
386
- ${selected.inputs
387
- .map(
388
- (input) => `<div class="barrow">
389
- <span class="name" title="${escapeHtml(input.source)}">${escapeHtml(input.source)}</span>
390
- <span class="size">${size(input.bytes)}</span>
391
- <span class="track"><span style="width:${Math.round((input.bytes / selected.inputs[0].bytes) * 100)}%"></span></span>
392
- </div>`,
393
- )
394
- .join("")}
395
- </div>
396
- ${
397
- selected.imports.length
398
- ? `<h2>Chunks it imports</h2>
399
- <div class="hint">${selected.imports
400
- .map((item) => `<div><code>${escapeHtml(item.path)}</code> <span class="tag">${escapeHtml(item.kind)}</span></div>`)
401
- .join("")}</div>`
402
- : ""
403
- }`
404
- : `<div class="hint">Pick an output on the left.</div>`
405
- }
406
- </div>
407
- </div>
408
- </div>
409
-
410
- <h2>Source groups<span class="note">package / folder share across all outputs</span></h2>
411
- <div class="card bars">
412
- ${(build.groups ?? [])
413
- .slice(0, 25)
414
- .map(
415
- (group) => `<div class="barrow">
416
- <span class="name">${escapeHtml(group.name)}</span>
417
- <span class="size">${size(group.bytes)}</span>
418
- <span class="track"><span style="width:${Math.round((group.bytes / groupTotal) * 100)}%"></span></span>
419
- </div>`,
420
- )
421
- .join("")}
422
- </div>
423
- `;
424
- }
425
-
426
- function assetsTab() {
427
- const assets = data.build?.assets ?? [];
428
- const total = assets.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
429
-
430
- return `
431
- <h2>Manifest assets<span class="note">${assets.length} entries · ${size(total)}</span></h2>
432
- ${table(
433
- "assets",
434
- [
435
- { key: "name", label: "Name" },
436
- { key: "url", label: "Path", render: (row) => link(row.url) },
437
- { key: "kind", label: "Kind", render: (row) => `<span class="tag">${escapeHtml(row.kind)}</span>` },
438
- { key: "bytes", label: "Raw", value: (row) => row.bytes ?? 0, render: (row) => size(row.bytes) },
439
- { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
440
- { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
441
- {
442
- key: "ratio",
443
- label: "Savings",
444
- value: (row) => (row.bytes && row.gzip ? 1 - row.gzip / row.bytes : 0),
445
- render: (row) =>
446
- row.bytes && row.gzip
447
- ? `${Math.round((1 - row.gzip / row.bytes) * 100)}%`
448
- : "—",
449
- },
450
- ],
451
- assets,
452
- { search: true },
453
- )}
454
- `;
455
- }
456
-
457
- function ssrTab() {
458
- const pages = data.pages ?? [];
459
- const cache = data.cache?.entries ?? [];
460
-
461
- /** @type {Map<string, { name: string, pages: number }>} */
462
- const islands = new Map();
463
- for (const page of pages) {
464
- for (const name of page.islands?.names ?? []) {
465
- const entry = islands.get(name) ?? { name, pages: 0 };
466
- entry.pages += 1;
467
- islands.set(name, entry);
468
- }
469
- }
470
-
471
- const csr = pages.filter((page) => (page.islands?.total ?? 0) > 0);
472
- const partial = csr.filter((page) => page.islands.ready < page.islands.total);
473
-
474
- return `
475
- <h2>Server render & client hydration</h2>
476
- ${metrics([
477
- { label: "SSR pages", value: String(pages.filter((page) => page.html?.bytes).length), sub: "HTML size known" },
478
- { label: "Pages with islands", value: `${csr.length}/${pages.length}`, ratio: pages.length ? csr.length / pages.length : null, tone: "good" },
479
- { label: "Partial hydration", value: String(partial.length), tone: partial.length ? "mid" : "good", sub: "some islands did not mount" },
480
- { label: "Distinct islands", value: String(islands.size) },
481
- { label: "Cache entries", value: String(data.cache?.size ?? 0) },
482
- { label: "Cache size", value: size(cache.reduce((sum, item) => sum + item.bytes, 0)) },
483
- ])}
484
-
485
- <h2>Island inventory<span class="note">islands seen on the pages you browsed</span></h2>
486
- ${table(
487
- "islands",
488
- [
489
- { key: "name", label: "Island" },
490
- { key: "pages", label: "Page count", value: (row) => row.pages },
491
- ],
492
- [...islands.values()],
493
- { search: true },
494
- )}
495
-
496
- <h2>HTML cache<span class="note">entries in memory, with time to stale</span></h2>
497
- ${table(
498
- "cache",
499
- [
500
- { key: "key", label: "Key" },
501
- { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 400 ? "bad" : ""}">${row.status}</span>` },
502
- { key: "bytes", label: "HTML", value: (row) => row.bytes, render: (row) => size(row.bytes) },
503
- { key: "expiresIn", label: "Freshness", value: (row) => row.expiresIn, render: (row) => (row.stale ? `<span class="mid">stale</span>` : `${row.expiresIn} s`) },
504
- { key: "encodings", label: "Compression", render: (row) => (row.encodings.length ? row.encodings.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join(" ") : "—") },
505
- ],
506
- cache,
507
- { search: true },
508
- )}
509
- `;
510
- }
511
-
512
- function apiTab() {
513
- const server = data.serverApi ?? [];
514
-
515
- /** @type {Map<string, { key: string, host: string, calls: number, ms: number, errors: number, bytes: number }>} */
516
- const grouped = new Map();
517
- for (const call of server) {
518
- let route = call.url;
519
- try {
520
- route = new URL(call.url).pathname;
521
- } catch {
522
- // Göreli adres.
523
- }
524
- const key = `${call.host}${route}`;
525
- const entry = grouped.get(key) ?? { key: route, host: call.host, calls: 0, ms: 0, errors: 0, bytes: 0 };
526
- entry.calls += 1;
527
- entry.ms += call.ms;
528
- entry.errors += call.error ? 1 : 0;
529
- entry.bytes += call.bytes;
530
- grouped.set(key, entry);
531
- }
532
-
533
- const client = (data.pages ?? []).flatMap((page) =>
534
- (page.api ?? []).map((call) => ({ ...call, page: page.url })),
535
- );
536
-
537
- return `
538
- <h2>Server side calls<span class="note">outbound requests made during SSR, grouped by path</span></h2>
539
- ${table(
540
- "api-groups",
541
- [
542
- { key: "key", label: "Path" },
543
- { key: "host", label: "Host" },
544
- { key: "calls", label: "Calls", value: (row) => row.calls },
545
- { key: "avg", label: "Avg. time", value: (row) => row.ms / row.calls, render: (row) => `<span class="${tone(row.ms / row.calls, [200, 800])}">${ms(row.ms / row.calls)}</span>` },
546
- { key: "total", label: "Total time", value: (row) => row.ms, render: (row) => ms(row.ms) },
547
- { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
548
- { key: "errors", label: "Errors", value: (row) => row.errors, render: (row) => (row.errors ? `<span class="bad">${row.errors}</span>` : "0") },
549
- ],
550
- [...grouped.values()],
551
- { search: true },
552
- )}
553
-
554
- <h2>Recent calls</h2>
555
- ${table(
556
- "api-calls",
557
- [
558
- { key: "url", label: "URL" },
559
- { key: "page", label: "Page", render: (row) => (row.page ? link(row.page) : "—") },
560
- { key: "method", label: "Method" },
561
- { key: "status", label: "Status", render: (row) => `<span class="status ${row.error ? "bad" : ""}">${row.status || "—"}</span>` },
562
- { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
563
- { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
564
- { key: "error", label: "Error", render: (row) => (row.error ? `<span class="bad">${escapeHtml(row.error)}</span>` : "—") },
565
- ],
566
- server.slice(0, 200),
567
- { search: true },
568
- )}
569
-
570
- <h2>Browser side calls<span class="note">fetch requests made by islands</span></h2>
571
- ${table(
572
- "api-client",
573
- [
574
- { key: "url", label: "URL" },
575
- { key: "page", label: "Page", render: (row) => link(row.page) },
576
- { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 400 || !row.status ? "bad" : ""}">${row.status || "—"}</span>` },
577
- { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
578
- { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
579
- ],
580
- client,
581
- { search: true },
582
- )}
583
- `;
584
- }
585
-
586
- function errorsTab() {
587
- return `
588
- <h2>Errors<span class="note">server log</span></h2>
589
- ${table(
590
- "errors",
591
- [
592
- { key: "message", label: "Message" },
593
- { key: "page", label: "Page", render: (row) => (row.page ? link(row.page) : "—") },
594
- {
595
- key: "details",
596
- label: "Details",
597
- render: (row) => {
598
- if (row.details == null) return "—";
599
- const text =
600
- typeof row.details === "string"
601
- ? row.details
602
- : (() => {
603
- try {
604
- return JSON.stringify(row.details);
605
- } catch {
606
- return String(row.details);
607
- }
608
- })();
609
- return `<span class="hint" title="${escapeHtml(text)}">${escapeHtml(text.slice(0, 120))}${text.length > 120 ? "…" : ""}</span>`;
610
- },
611
- },
612
- { key: "level", label: "Level", render: (row) => `<span class="tag">${escapeHtml(row.level)}</span>` },
613
- { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
614
- ],
615
- data.errors ?? [],
616
- { search: true },
617
- )}
618
-
619
- <h2>Recent requests</h2>
620
- ${table(
621
- "requests",
622
- [
623
- { key: "url", label: "URL", render: (row) => link(row.url) },
624
- { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 500 ? "bad" : row.status >= 400 ? "warn" : ""}">${row.status}</span>` },
625
- { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [150, 500])}">${ms(row.ms)}</span>` },
626
- { key: "cache", label: "Cache", render: (row) => (row.cache ? `<span class="tag">${escapeHtml(row.cache)}</span>` : "—") },
627
- { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
628
- ],
629
- data.requests ?? [],
630
- { search: true },
631
- )}
632
- `;
633
- }
634
-
635
- const RENDERERS = {
636
- overview,
637
- pages: pagesTab,
638
- chunks: chunksTab,
639
- assets: assetsTab,
640
- ssr: ssrTab,
641
- api: apiTab,
642
- errors: errorsTab,
643
- };
644
-
645
- /* ------------------------------------------------------------------ çizim */
646
-
647
- function render() {
648
- const nav = document.querySelector("[data-part='tabs']");
649
- nav.innerHTML = TABS.map(
650
- (tab) =>
651
- `<button data-tab="${tab.id}" aria-selected="${tab.id === activeTab}">${tab.label}</button>`,
652
- ).join("");
653
-
654
- const main = document.querySelector("[data-part='main']");
655
- main.innerHTML = data
656
- ? RENDERERS[activeTab]()
657
- : `<div class="empty">Loading data…</div>`;
658
-
659
- const status = document.querySelector("[data-part='status']");
660
- if (data) {
661
- const time = new Date(data.generatedAt).toLocaleTimeString();
662
- status.textContent = `${data.pages.length} pages · ${time}`;
663
- }
664
-
665
- document.querySelector("[data-action='auto']").setAttribute("aria-pressed", String(auto));
666
- }
667
-
668
- async function load() {
669
- try {
670
- const response = await fetch(`${BASE}/report/data`, { cache: "no-store" });
671
- data = await response.json();
672
- } catch {
673
- document.querySelector("[data-part='status']").textContent = "server unreachable";
674
- return;
675
- }
676
- render();
677
- }
678
-
679
- /* ------------------------------------------------------------ etkileşim */
680
-
681
- document.addEventListener("click", (event) => {
682
- const target = event.target.closest("[data-tab], [data-sort], [data-chunk], [data-action]");
683
- if (!target) return;
684
-
685
- if (target.dataset.action === "refresh") {
686
- load();
687
- return;
688
- }
689
-
690
- if (target.dataset.action === "auto") {
691
- auto = !auto;
692
- clearInterval(timer);
693
- if (auto) timer = setInterval(load, 5000);
694
- render();
695
- return;
696
- }
697
-
698
- if (target.dataset.action === "reset") {
699
- fetch(`${BASE}/report/clear`, { method: "POST" }).finally(load);
700
- return;
701
- }
702
-
703
- if (target.dataset.tab) {
704
- activeTab = target.dataset.tab;
705
- location.hash = activeTab;
706
- render();
707
- return;
708
- }
709
-
710
- if (target.dataset.sort) {
711
- const id = target.closest("table").dataset.table;
712
- const current = sorting.get(id);
713
- sorting.set(id, {
714
- key: target.dataset.sort,
715
- dir: current?.key === target.dataset.sort && current.dir === "desc" ? "asc" : "desc",
716
- });
717
- render();
718
- return;
719
- }
720
-
721
- if (target.dataset.chunk) {
722
- selectedChunk = target.dataset.chunk;
723
- render();
724
- }
725
- });
726
-
727
- document.addEventListener("input", (event) => {
728
- const field = event.target.closest("[data-filter]");
729
- if (!field) return;
730
-
731
- filters.set(field.dataset.filter, field.value);
732
- render();
733
-
734
- // Yeniden çizim odağı düşürür; yazmaya devam edilebilsin.
735
- const next = document.querySelector(`[data-filter="${field.dataset.filter}"]`);
736
- next?.focus();
737
- next?.setSelectionRange(field.value.length, field.value.length);
738
- });
739
-
740
- if (TABS.some((tab) => tab.id === location.hash.slice(1))) {
741
- activeTab = location.hash.slice(1);
742
- }
743
-
744
- render();
745
- load();
1
+ /**
2
+ * "Inspect in detail" 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: "Overview" },
29
+ { id: "pages", label: "Pages" },
30
+ { id: "chunks", label: "Chunk analysis" },
31
+ { id: "assets", label: "Assets" },
32
+ { id: "ssr", label: "SSR & CSR" },
33
+ { id: "api", label: "API requests" },
34
+ { id: "errors", label: "Errors" },
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 ?? ""));
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="Filter — ${visible.length}/${rows.length} rows" />
140
+ </div>`
141
+ : "";
142
+
143
+ if (!rows.length) {
144
+ return `${search}<div class="empty">No records.</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 wide<span class="note">${pages.length} pages known, ${measured.length} of them measured in the browser</span></h2>
195
+ ${metrics([
196
+ {
197
+ label: "Average LCP",
198
+ value: ms(average),
199
+ tone: tone(average, [2500, 4000]),
200
+ ratio: average == null ? null : average / 4000,
201
+ sub: `worst ${ms(worst[0]?.metrics?.lcp)}`,
202
+ },
203
+ {
204
+ label: "Measured pages",
205
+ value: `${measured.length}/${pages.length}`,
206
+ ratio: pages.length ? measured.length / pages.length : null,
207
+ tone: "good",
208
+ sub: "browse to measure",
209
+ },
210
+ { label: "JS output", value: size(jsBytes), sub: `${js.length} files` },
211
+ { label: "CSS output", value: size(cssBytes), sub: `${css.length} files` },
212
+ { label: "SSR HTML", value: size(ssrBytes), sub: "total of known pages" },
213
+ {
214
+ label: "Island (CSR)",
215
+ value: `${islandReady}/${islandTotal}`,
216
+ ratio: islandTotal ? islandReady / islandTotal : null,
217
+ tone: "good",
218
+ sub: "mounted / marked",
219
+ },
220
+ {
221
+ label: "HTML cache",
222
+ value: String(data.cache?.size ?? 0),
223
+ sub: `${size((data.cache?.entries ?? []).reduce((sum, item) => sum + item.bytes, 0))} in memory`,
224
+ },
225
+ {
226
+ label: "Server API calls",
227
+ value: String((data.serverApi ?? []).length),
228
+ tone: apiErrors ? "bad" : "good",
229
+ sub: apiErrors ? `${apiErrors} errors` : "no errors",
230
+ },
231
+ // Upstream kotasının nasıl harcandığı: okumaların kaçı önbellekten
232
+ // döndü. Oran düşükse `withDataCache` TTL'i kısa demektir.
233
+ {
234
+ label: "Data cache",
235
+ value: data.cache?.dataStats?.reads
236
+ ? `${Math.round(data.cache.dataStats.hitRatio * 100)}%`
237
+ : "—",
238
+ ratio: data.cache?.dataStats?.hitRatio ?? null,
239
+ tone: "good",
240
+ sub: data.cache?.dataStats?.reads
241
+ ? `${data.cache.dataStats.produced} upstream / ${data.cache.dataStats.reads} reads`
242
+ : "no reads yet",
243
+ },
244
+ {
245
+ label: "Prewarm",
246
+ value: data.prewarm?.total
247
+ ? `${data.prewarm.ok}/${data.prewarm.total}`
248
+ : "—",
249
+ ratio: data.prewarm?.total ? data.prewarm.done / data.prewarm.total : null,
250
+ tone: data.prewarm?.failed ? "mid" : "good",
251
+ },
252
+ {
253
+ label: "Process",
254
+ value: size(data.process?.memory?.rss),
255
+ sub: `${data.process?.node} · ${Math.round(data.process?.uptime ?? 0)} s`,
256
+ },
257
+ ])}
258
+
259
+ <h2>Slowest pages<span class="note">by LCP</span></h2>
260
+ ${
261
+ worst.length
262
+ ? table(
263
+ "worst",
264
+ [
265
+ { key: "url", label: "Page", render: (row) => link(row.url) },
266
+ { key: "lcp", label: "LCP", value: (row) => row.metrics.lcp, render: (row) => `<span class="${tone(row.metrics.lcp, [2500, 4000])}">${ms(row.metrics.lcp)}</span>` },
267
+ { key: "cls", label: "CLS", value: (row) => row.metrics.cls ?? 0, render: (row) => (row.metrics.cls ?? 0).toFixed(3) },
268
+ { key: "inp", label: "INP", value: (row) => row.metrics.inp ?? 0, render: (row) => ms(row.metrics.inp) },
269
+ { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
270
+ { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
271
+ ],
272
+ worst,
273
+ )
274
+ : `<div class="empty">No browser measurements yet. Pages land here as you browse the site.</div>`
275
+ }
276
+
277
+ <h2>Web Vitals distribution</h2>
278
+ <div class="card">
279
+ ${["lcp", "fcp", "ttfb", "inp"]
280
+ .map((key) => {
281
+ const values = measured
282
+ .map((page) => page.metrics[key])
283
+ .filter((value) => value != null)
284
+ .sort((a, b) => a - b);
285
+ if (!values.length) return "";
286
+ const p = (ratio) => values[Math.min(values.length - 1, Math.floor(values.length * ratio))];
287
+ const bounds = { lcp: [2500, 4000], fcp: [1800, 3000], ttfb: [800, 1800], inp: [200, 500] }[key];
288
+ return `<div class="barrow">
289
+ <span class="name">${key.toUpperCase()} — median ${ms(p(0.5))}, p75 ${ms(p(0.75))}, p95 ${ms(p(0.95))}</span>
290
+ <span class="size ${tone(p(0.75), bounds)}">${ms(p(0.75))}</span>
291
+ <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>
292
+ </div>`;
293
+ })
294
+ .join("")}
295
+ </div>
296
+ `;
297
+ }
298
+
299
+ function pagesTab() {
300
+ const rows = data.pages ?? [];
301
+
302
+ return `
303
+ <h2>Pages<span class="note">browser measurements build up as you browse; SSR columns come from the prewarm round</span></h2>
304
+ ${table(
305
+ "pages",
306
+ [
307
+ { key: "url", label: "Page", render: (row) => link(row.url) },
308
+ { key: "visits", label: "Visits", value: (row) => row.visits },
309
+ { 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>` },
310
+ { 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>`) },
311
+ { 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>` },
312
+ { key: "fcp", label: "FCP", value: (row) => row.metrics?.fcp ?? -1, render: (row) => ms(row.metrics?.fcp) },
313
+ { 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>` },
314
+ { key: "blocking", label: "Blocking", value: (row) => row.metrics?.blocking ?? -1, render: (row) => ms(row.metrics?.blocking) },
315
+ { key: "requests", label: "Requests", value: (row) => row.resources?.count ?? 0 },
316
+ { key: "transfer", label: "Transfer", value: (row) => row.resources?.bytes ?? 0, render: (row) => size(row.resources?.bytes) },
317
+ { key: "islands", label: "Island", value: (row) => row.islands?.total ?? 0, render: (row) => (row.islands?.total ? `${row.islands.ready}/${row.islands.total}` : "—") },
318
+ { key: "api", label: "API", value: (row) => row.api?.length ?? 0 },
319
+ { key: "html", label: "SSR HTML", value: (row) => row.html?.bytes ?? 0, render: (row) => size(row.html?.bytes) },
320
+ { key: "render", label: "Render", value: (row) => row.html?.ms ?? -1, render: (row) => ms(row.html?.ms) },
321
+ { key: "cache", label: "Cache", render: (row) => (row.html?.cache ? `<span class="tag">${escapeHtml(row.html.cache)}</span>` : "—") },
322
+ ],
323
+ rows,
324
+ { search: true },
325
+ )}
326
+ `;
327
+ }
328
+
329
+ function chunksTab() {
330
+ const build = data.build ?? {};
331
+ if (!build.available) {
332
+ return `<div class="empty">Chunk analysis needs <code>build/generated/metafile.json</code>. It appears once <code>npm run dev</code> or <code>npm run build</code> has completed a round.</div>`;
333
+ }
334
+
335
+ const outputs = build.outputs ?? [];
336
+ const selected =
337
+ outputs.find((item) => item.file === selectedChunk) ?? outputs[0];
338
+ const heaviest = outputs[0]?.bytes ?? 1;
339
+ const totalBytes = outputs.reduce((sum, item) => sum + item.bytes, 0);
340
+ const groupTotal = (build.groups ?? []).reduce((sum, item) => sum + item.bytes, 0) || 1;
341
+
342
+ return `
343
+ <h2>Outputs<span class="note">${outputs.length} files · ${size(totalBytes)} raw</span></h2>
344
+ <div class="split">
345
+ <div>
346
+ ${table(
347
+ "chunks",
348
+ [
349
+ {
350
+ key: "file",
351
+ label: "File",
352
+ render: (row) =>
353
+ `${link(row.file)} <span class="tag ${row.isChunk ? "chunk" : "entry"}">${row.isChunk ? "chunk" : "entry"}</span>`,
354
+ },
355
+ { key: "bytes", label: "Raw", value: (row) => row.bytes, render: (row) => size(row.bytes) },
356
+ { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
357
+ { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
358
+ { key: "inputCount", label: "Modules", value: (row) => row.inputCount },
359
+ { key: "imports", label: "Imports", value: (row) => row.imports.length },
360
+ {
361
+ key: "share",
362
+ label: "Share",
363
+ value: (row) => row.bytes / heaviest,
364
+ render: (row) => `${Math.round((row.bytes / totalBytes) * 100)}%`,
365
+ },
366
+ ],
367
+ outputs,
368
+ {
369
+ search: true,
370
+ rowAttrs: (row) =>
371
+ `class="clickable ${row.file === selected?.file ? "selected" : ""}" data-chunk="${escapeHtml(row.file)}"`,
372
+ },
373
+ )}
374
+ </div>
375
+ <div>
376
+ <div class="card">
377
+ <h2 style="margin-top:0">Selected output</h2>
378
+ ${
379
+ selected
380
+ ? `<div class="hint" style="margin-bottom:10px">
381
+ <code>${escapeHtml(selected.file)}</code><br />
382
+ ${size(selected.bytes)} raw · ${size(selected.gzip)} gzip · ${selected.inputCount} modules
383
+ ${selected.entry ? `<br />entry: <code>${escapeHtml(selected.entry)}</code>` : ""}
384
+ </div>
385
+ <div class="bars">
386
+ ${selected.inputs
387
+ .map(
388
+ (input) => `<div class="barrow">
389
+ <span class="name" title="${escapeHtml(input.source)}">${escapeHtml(input.source)}</span>
390
+ <span class="size">${size(input.bytes)}</span>
391
+ <span class="track"><span style="width:${Math.round((input.bytes / selected.inputs[0].bytes) * 100)}%"></span></span>
392
+ </div>`,
393
+ )
394
+ .join("")}
395
+ </div>
396
+ ${
397
+ selected.imports.length
398
+ ? `<h2>Chunks it imports</h2>
399
+ <div class="hint">${selected.imports
400
+ .map((item) => `<div><code>${escapeHtml(item.path)}</code> <span class="tag">${escapeHtml(item.kind)}</span></div>`)
401
+ .join("")}</div>`
402
+ : ""
403
+ }`
404
+ : `<div class="hint">Pick an output on the left.</div>`
405
+ }
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
+ <h2>Source groups<span class="note">package / folder share across all outputs</span></h2>
411
+ <div class="card bars">
412
+ ${(build.groups ?? [])
413
+ .slice(0, 25)
414
+ .map(
415
+ (group) => `<div class="barrow">
416
+ <span class="name">${escapeHtml(group.name)}</span>
417
+ <span class="size">${size(group.bytes)}</span>
418
+ <span class="track"><span style="width:${Math.round((group.bytes / groupTotal) * 100)}%"></span></span>
419
+ </div>`,
420
+ )
421
+ .join("")}
422
+ </div>
423
+ `;
424
+ }
425
+
426
+ function assetsTab() {
427
+ const assets = data.build?.assets ?? [];
428
+ const total = assets.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
429
+
430
+ return `
431
+ <h2>Manifest assets<span class="note">${assets.length} entries · ${size(total)}</span></h2>
432
+ ${table(
433
+ "assets",
434
+ [
435
+ { key: "name", label: "Name" },
436
+ { key: "url", label: "Path", render: (row) => link(row.url) },
437
+ { key: "kind", label: "Kind", render: (row) => `<span class="tag">${escapeHtml(row.kind)}</span>` },
438
+ { key: "bytes", label: "Raw", value: (row) => row.bytes ?? 0, render: (row) => size(row.bytes) },
439
+ { key: "gzip", label: "Gzip", value: (row) => row.gzip ?? 0, render: (row) => size(row.gzip) },
440
+ { key: "brotli", label: "Brotli", value: (row) => row.brotli ?? 0, render: (row) => size(row.brotli) },
441
+ {
442
+ key: "ratio",
443
+ label: "Savings",
444
+ value: (row) => (row.bytes && row.gzip ? 1 - row.gzip / row.bytes : 0),
445
+ render: (row) =>
446
+ row.bytes && row.gzip
447
+ ? `${Math.round((1 - row.gzip / row.bytes) * 100)}%`
448
+ : "—",
449
+ },
450
+ ],
451
+ assets,
452
+ { search: true },
453
+ )}
454
+ `;
455
+ }
456
+
457
+ function ssrTab() {
458
+ const pages = data.pages ?? [];
459
+ const cache = data.cache?.entries ?? [];
460
+
461
+ /** @type {Map<string, { name: string, pages: number }>} */
462
+ const islands = new Map();
463
+ for (const page of pages) {
464
+ for (const name of page.islands?.names ?? []) {
465
+ const entry = islands.get(name) ?? { name, pages: 0 };
466
+ entry.pages += 1;
467
+ islands.set(name, entry);
468
+ }
469
+ }
470
+
471
+ const csr = pages.filter((page) => (page.islands?.total ?? 0) > 0);
472
+ const partial = csr.filter((page) => page.islands.ready < page.islands.total);
473
+
474
+ return `
475
+ <h2>Server render & client hydration</h2>
476
+ ${metrics([
477
+ { label: "SSR pages", value: String(pages.filter((page) => page.html?.bytes).length), sub: "HTML size known" },
478
+ { label: "Pages with islands", value: `${csr.length}/${pages.length}`, ratio: pages.length ? csr.length / pages.length : null, tone: "good" },
479
+ { label: "Partial hydration", value: String(partial.length), tone: partial.length ? "mid" : "good", sub: "some islands did not mount" },
480
+ { label: "Distinct islands", value: String(islands.size) },
481
+ { label: "Cache entries", value: String(data.cache?.size ?? 0) },
482
+ { label: "Cache size", value: size(cache.reduce((sum, item) => sum + item.bytes, 0)) },
483
+ ])}
484
+
485
+ <h2>Island inventory<span class="note">islands seen on the pages you browsed</span></h2>
486
+ ${table(
487
+ "islands",
488
+ [
489
+ { key: "name", label: "Island" },
490
+ { key: "pages", label: "Page count", value: (row) => row.pages },
491
+ ],
492
+ [...islands.values()],
493
+ { search: true },
494
+ )}
495
+
496
+ <h2>HTML cache<span class="note">entries in memory, with time to stale</span></h2>
497
+ ${table(
498
+ "cache",
499
+ [
500
+ { key: "key", label: "Key" },
501
+ { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 400 ? "bad" : ""}">${row.status}</span>` },
502
+ { key: "bytes", label: "HTML", value: (row) => row.bytes, render: (row) => size(row.bytes) },
503
+ { key: "expiresIn", label: "Freshness", value: (row) => row.expiresIn, render: (row) => (row.stale ? `<span class="mid">stale</span>` : `${row.expiresIn} s`) },
504
+ { key: "encodings", label: "Compression", render: (row) => (row.encodings.length ? row.encodings.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join(" ") : "—") },
505
+ ],
506
+ cache,
507
+ { search: true },
508
+ )}
509
+ `;
510
+ }
511
+
512
+ function apiTab() {
513
+ const server = data.serverApi ?? [];
514
+
515
+ /** @type {Map<string, { key: string, host: string, calls: number, ms: number, errors: number, bytes: number }>} */
516
+ const grouped = new Map();
517
+ for (const call of server) {
518
+ let route = call.url;
519
+ try {
520
+ route = new URL(call.url).pathname;
521
+ } catch {
522
+ // Göreli adres.
523
+ }
524
+ const key = `${call.host}${route}`;
525
+ const entry = grouped.get(key) ?? { key: route, host: call.host, calls: 0, ms: 0, errors: 0, bytes: 0 };
526
+ entry.calls += 1;
527
+ entry.ms += call.ms;
528
+ entry.errors += call.error ? 1 : 0;
529
+ entry.bytes += call.bytes;
530
+ grouped.set(key, entry);
531
+ }
532
+
533
+ const client = (data.pages ?? []).flatMap((page) =>
534
+ (page.api ?? []).map((call) => ({ ...call, page: page.url })),
535
+ );
536
+
537
+ return `
538
+ <h2>Server side calls<span class="note">outbound requests made during SSR, grouped by path</span></h2>
539
+ ${table(
540
+ "api-groups",
541
+ [
542
+ { key: "key", label: "Path" },
543
+ { key: "host", label: "Host" },
544
+ { key: "calls", label: "Calls", value: (row) => row.calls },
545
+ { key: "avg", label: "Avg. time", value: (row) => row.ms / row.calls, render: (row) => `<span class="${tone(row.ms / row.calls, [200, 800])}">${ms(row.ms / row.calls)}</span>` },
546
+ { key: "total", label: "Total time", value: (row) => row.ms, render: (row) => ms(row.ms) },
547
+ { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
548
+ { key: "errors", label: "Errors", value: (row) => row.errors, render: (row) => (row.errors ? `<span class="bad">${row.errors}</span>` : "0") },
549
+ ],
550
+ [...grouped.values()],
551
+ { search: true },
552
+ )}
553
+
554
+ <h2>Recent calls</h2>
555
+ ${table(
556
+ "api-calls",
557
+ [
558
+ { key: "url", label: "URL" },
559
+ { key: "page", label: "Page", render: (row) => (row.page ? link(row.page) : "—") },
560
+ { key: "method", label: "Method" },
561
+ { key: "status", label: "Status", render: (row) => `<span class="status ${row.error ? "bad" : ""}">${row.status || "—"}</span>` },
562
+ { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
563
+ { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
564
+ { key: "error", label: "Error", render: (row) => (row.error ? `<span class="bad">${escapeHtml(row.error)}</span>` : "—") },
565
+ ],
566
+ server.slice(0, 200),
567
+ { search: true },
568
+ )}
569
+
570
+ <h2>Browser side calls<span class="note">fetch requests made by islands</span></h2>
571
+ ${table(
572
+ "api-client",
573
+ [
574
+ { key: "url", label: "URL" },
575
+ { key: "page", label: "Page", render: (row) => link(row.page) },
576
+ { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 400 || !row.status ? "bad" : ""}">${row.status || "—"}</span>` },
577
+ { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [200, 800])}">${ms(row.ms)}</span>` },
578
+ { key: "bytes", label: "Body", value: (row) => row.bytes, render: (row) => size(row.bytes) },
579
+ ],
580
+ client,
581
+ { search: true },
582
+ )}
583
+ `;
584
+ }
585
+
586
+ function errorsTab() {
587
+ return `
588
+ <h2>Errors<span class="note">server log</span></h2>
589
+ ${table(
590
+ "errors",
591
+ [
592
+ { key: "message", label: "Message" },
593
+ { key: "page", label: "Page", render: (row) => (row.page ? link(row.page) : "—") },
594
+ {
595
+ key: "details",
596
+ label: "Details",
597
+ render: (row) => {
598
+ if (row.details == null) return "—";
599
+ const text =
600
+ typeof row.details === "string"
601
+ ? row.details
602
+ : (() => {
603
+ try {
604
+ return JSON.stringify(row.details);
605
+ } catch {
606
+ return String(row.details);
607
+ }
608
+ })();
609
+ return `<span class="hint" title="${escapeHtml(text)}">${escapeHtml(text.slice(0, 120))}${text.length > 120 ? "…" : ""}</span>`;
610
+ },
611
+ },
612
+ { key: "level", label: "Level", render: (row) => `<span class="tag">${escapeHtml(row.level)}</span>` },
613
+ { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
614
+ ],
615
+ data.errors ?? [],
616
+ { search: true },
617
+ )}
618
+
619
+ <h2>Recent requests</h2>
620
+ ${table(
621
+ "requests",
622
+ [
623
+ { key: "url", label: "URL", render: (row) => link(row.url) },
624
+ { key: "status", label: "Status", render: (row) => `<span class="status ${row.status >= 500 ? "bad" : row.status >= 400 ? "warn" : ""}">${row.status}</span>` },
625
+ { key: "ms", label: "Time", value: (row) => row.ms, render: (row) => `<span class="${tone(row.ms, [150, 500])}">${ms(row.ms)}</span>` },
626
+ { key: "cache", label: "Cache", render: (row) => (row.cache ? `<span class="tag">${escapeHtml(row.cache)}</span>` : "—") },
627
+ { key: "at", label: "Time", value: (row) => row.at, render: (row) => new Date(row.at).toLocaleTimeString() },
628
+ ],
629
+ data.requests ?? [],
630
+ { search: true },
631
+ )}
632
+ `;
633
+ }
634
+
635
+ const RENDERERS = {
636
+ overview,
637
+ pages: pagesTab,
638
+ chunks: chunksTab,
639
+ assets: assetsTab,
640
+ ssr: ssrTab,
641
+ api: apiTab,
642
+ errors: errorsTab,
643
+ };
644
+
645
+ /* ------------------------------------------------------------------ çizim */
646
+
647
+ function render() {
648
+ const nav = document.querySelector("[data-part='tabs']");
649
+ nav.innerHTML = TABS.map(
650
+ (tab) =>
651
+ `<button data-tab="${tab.id}" aria-selected="${tab.id === activeTab}">${tab.label}</button>`,
652
+ ).join("");
653
+
654
+ const main = document.querySelector("[data-part='main']");
655
+ main.innerHTML = data
656
+ ? RENDERERS[activeTab]()
657
+ : `<div class="empty">Loading data…</div>`;
658
+
659
+ const status = document.querySelector("[data-part='status']");
660
+ if (data) {
661
+ const time = new Date(data.generatedAt).toLocaleTimeString();
662
+ status.textContent = `${data.pages.length} pages · ${time}`;
663
+ }
664
+
665
+ document.querySelector("[data-action='auto']").setAttribute("aria-pressed", String(auto));
666
+ }
667
+
668
+ async function load() {
669
+ try {
670
+ const response = await fetch(`${BASE}/report/data`, { cache: "no-store" });
671
+ data = await response.json();
672
+ } catch {
673
+ document.querySelector("[data-part='status']").textContent = "server unreachable";
674
+ return;
675
+ }
676
+ render();
677
+ }
678
+
679
+ /* ------------------------------------------------------------ etkileşim */
680
+
681
+ document.addEventListener("click", (event) => {
682
+ const target = event.target.closest("[data-tab], [data-sort], [data-chunk], [data-action]");
683
+ if (!target) return;
684
+
685
+ if (target.dataset.action === "refresh") {
686
+ load();
687
+ return;
688
+ }
689
+
690
+ if (target.dataset.action === "auto") {
691
+ auto = !auto;
692
+ clearInterval(timer);
693
+ if (auto) timer = setInterval(load, 5000);
694
+ render();
695
+ return;
696
+ }
697
+
698
+ if (target.dataset.action === "reset") {
699
+ fetch(`${BASE}/report/clear`, { method: "POST" }).finally(load);
700
+ return;
701
+ }
702
+
703
+ if (target.dataset.tab) {
704
+ activeTab = target.dataset.tab;
705
+ location.hash = activeTab;
706
+ render();
707
+ return;
708
+ }
709
+
710
+ if (target.dataset.sort) {
711
+ const id = target.closest("table").dataset.table;
712
+ const current = sorting.get(id);
713
+ sorting.set(id, {
714
+ key: target.dataset.sort,
715
+ dir: current?.key === target.dataset.sort && current.dir === "desc" ? "asc" : "desc",
716
+ });
717
+ render();
718
+ return;
719
+ }
720
+
721
+ if (target.dataset.chunk) {
722
+ selectedChunk = target.dataset.chunk;
723
+ render();
724
+ }
725
+ });
726
+
727
+ document.addEventListener("input", (event) => {
728
+ const field = event.target.closest("[data-filter]");
729
+ if (!field) return;
730
+
731
+ filters.set(field.dataset.filter, field.value);
732
+ render();
733
+
734
+ // Yeniden çizim odağı düşürür; yazmaya devam edilebilsin.
735
+ const next = document.querySelector(`[data-filter="${field.dataset.filter}"]`);
736
+ next?.focus();
737
+ next?.setSelectionRange(field.value.length, field.value.length);
738
+ });
739
+
740
+ if (TABS.some((tab) => tab.id === location.hash.slice(1))) {
741
+ activeTab = location.hash.slice(1);
742
+ }
743
+
744
+ render();
745
+ load();