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.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +620 -596
- package/LICENSE +21 -21
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -309
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +661 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1443 -1423
- package/docs/07-yapilandirma.md +12 -6
- package/docs/08-build.md +429 -428
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +338 -338
- package/docs/12-panel-ve-oturum.md +478 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -328
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +669 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1453 -1431
- package/docs/en/07-configuration.md +1219 -1214
- package/docs/en/08-build.md +447 -446
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +340 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +488 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +17 -1
- package/src/config/index.js +13 -0
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +230 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -462
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -0
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1122
- package/src/server/image-optimizer.js +500 -407
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -66
- package/src/server/logs/pipeline.js +165 -158
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -100
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +356 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1058
- package/src/server/redis.js +588 -569
- package/src/server/render.js +4 -4
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +15 -1
- package/types/config/index.d.ts +8 -0
- package/types/server/cache-blob.d.ts +13 -0
- package/types/server/data-cache.d.ts +9 -0
- package/types/server/disk-cache.d.ts +36 -0
- package/types/server/html-cache.d.ts +26 -3
- package/types/server/logs/file-sink.d.ts +16 -5
- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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();
|