jskelet 0.2.1 → 0.2.2

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.
@@ -0,0 +1,133 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
7
+ <meta name="referrer" content="no-referrer" />
8
+ <title>Cache</title>
9
+ <link rel="stylesheet" href="panel.css" />
10
+ </head>
11
+ <body>
12
+ <header>
13
+ <h1>Cache</h1>
14
+ <span class="chip" id="env">—</span>
15
+ <span class="chip" id="pid">—</span>
16
+ <span class="chip" id="uptime">—</span>
17
+ <span class="chip" id="memory">—</span>
18
+ <span class="spacer"></span>
19
+ <label class="check">
20
+ <input type="checkbox" id="auto" checked />
21
+ live
22
+ </label>
23
+ <button id="refresh">Refresh</button>
24
+ <button id="logout">Sign out</button>
25
+ </header>
26
+
27
+ <main>
28
+ <div class="cards">
29
+ <div class="card">
30
+ <div class="label">HTML entries</div>
31
+ <div class="value" id="html-size">—</div>
32
+ <div class="sub" id="html-sub">—</div>
33
+ </div>
34
+ <div class="card">
35
+ <div class="label">HTML in memory</div>
36
+ <div class="value" id="html-bytes">—</div>
37
+ <div class="sub" id="html-stale">—</div>
38
+ </div>
39
+ <div class="card">
40
+ <div class="label">Data entries</div>
41
+ <div class="value" id="data-size">—</div>
42
+ <div class="sub" id="data-sub">—</div>
43
+ </div>
44
+ <div class="card">
45
+ <div class="label">Shared tier</div>
46
+ <div class="value" id="redis-state">—</div>
47
+ <div class="sub" id="redis-sub">—</div>
48
+ </div>
49
+ <div class="card">
50
+ <div class="label">Prewarm</div>
51
+ <div class="value" id="prewarm-state">—</div>
52
+ <div class="sub" id="prewarm-sub">—</div>
53
+ </div>
54
+ </div>
55
+
56
+ <section>
57
+ <h2>Actions</h2>
58
+ <div class="body">
59
+ <div class="row">
60
+ <input
61
+ class="grow"
62
+ type="text"
63
+ id="target"
64
+ spellcheck="false"
65
+ placeholder="/news/:slug · /news/some-post · invalidation target"
66
+ />
67
+ <label class="check">
68
+ <input type="checkbox" id="hard" />
69
+ hard (delete instead of marking stale)
70
+ </label>
71
+ <button class="primary" id="invalidate">Invalidate</button>
72
+ </div>
73
+
74
+ <div class="row">
75
+ <input
76
+ class="grow"
77
+ type="text"
78
+ id="prefix"
79
+ spellcheck="false"
80
+ placeholder="data key prefix — empty clears every data entry"
81
+ />
82
+ <button class="danger" id="clear-data">Clear data cache</button>
83
+ <button class="danger" id="clear-html">Clear HTML cache</button>
84
+ </div>
85
+
86
+ <div class="row">
87
+ <button id="prewarm">Prewarm all paths</button>
88
+ <button id="redis-html">Drop shared HTML keys</button>
89
+ <button id="redis-data">Drop shared data keys</button>
90
+ </div>
91
+
92
+ <p class="hint">
93
+ Invalidation marks entries stale by default: visitors keep getting
94
+ the old HTML while a single background refresh runs. Hard mode
95
+ deletes them, so the next visitor waits for a cold render.
96
+ </p>
97
+ </div>
98
+ </section>
99
+
100
+ <section>
101
+ <h2>
102
+ Entries
103
+ <span class="count" id="listed">—</span>
104
+ <span class="tabs" role="tablist">
105
+ <button id="tab-html" role="tab" aria-selected="true">HTML</button>
106
+ <button id="tab-data" role="tab" aria-selected="false">Data</button>
107
+ </span>
108
+ </h2>
109
+ <div class="body">
110
+ <div class="row">
111
+ <input
112
+ class="grow"
113
+ type="search"
114
+ id="search"
115
+ spellcheck="false"
116
+ placeholder="Filter by key"
117
+ />
118
+ </div>
119
+ </div>
120
+ <div class="scroll">
121
+ <table>
122
+ <thead id="thead"></thead>
123
+ <tbody id="tbody"></tbody>
124
+ </table>
125
+ <div class="empty" id="empty" hidden>No entries.</div>
126
+ </div>
127
+ </section>
128
+ </main>
129
+
130
+ <div id="toast"></div>
131
+ <script type="module" src="panel.js"></script>
132
+ </body>
133
+ </html>
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Önbellek panelinin istemci tarafı.
3
+ *
4
+ * Build hattından geçmez, doğrudan diskten servis edilir: panelin açık olduğu
5
+ * bir kurulumda `jskelet build` hiç koşmamış olabilir ve panel yine çalışmalı.
6
+ * Bu yüzden burada import edilen bir framework modülü yok.
7
+ *
8
+ * Yenileme WebSocket değil, kısa aralıklı `fetch` ile: panel nadiren ve kısa
9
+ * süre açık kalıyor, kalıcı bir kanal açmanın karşılığı yok.
10
+ */
11
+
12
+ const REFRESH_MS = 3000;
13
+
14
+ const $ = (/** @type {string} */ id) => document.getElementById(id);
15
+
16
+ /** @type {"html" | "data"} */
17
+ let tab = "html";
18
+ let query = "";
19
+ /** @type {any} */
20
+ let latest = null;
21
+ /** @type {number | null} */
22
+ let timer = null;
23
+ let busy = false;
24
+
25
+ /* ------------------------------------------------------------ biçimlendirme */
26
+
27
+ /**
28
+ * @param {number} bytes
29
+ * @returns {string}
30
+ */
31
+ function formatBytes(bytes) {
32
+ if (!bytes) return "0 B";
33
+ const units = ["B", "KB", "MB", "GB"];
34
+ const power = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
35
+ const value = bytes / 1024 ** power;
36
+ return `${value >= 100 || power === 0 ? Math.round(value) : value.toFixed(1)} ${units[power]}`;
37
+ }
38
+
39
+ /**
40
+ * @param {number} seconds
41
+ * @returns {string}
42
+ */
43
+ function formatDuration(seconds) {
44
+ const total = Math.max(0, Math.round(seconds));
45
+ if (total < 60) return `${total}s`;
46
+ if (total < 3600) return `${Math.floor(total / 60)}m ${total % 60}s`;
47
+ if (total < 86_400) return `${Math.floor(total / 3600)}h ${Math.floor((total % 3600) / 60)}m`;
48
+ return `${Math.floor(total / 86_400)}d ${Math.floor((total % 86_400) / 3600)}h`;
49
+ }
50
+
51
+ /**
52
+ * @param {string} message
53
+ * @param {boolean} [ok]
54
+ */
55
+ function toast(message, ok = true) {
56
+ const node = document.createElement("div");
57
+ node.className = ok ? "toast" : "toast bad";
58
+ node.textContent = message;
59
+ $("toast").append(node);
60
+
61
+ setTimeout(() => node.remove(), 5000);
62
+ }
63
+
64
+ /* -------------------------------------------------------------------- veri */
65
+
66
+ /**
67
+ * Sunucu yetkisiz isteklere 404 dönüyor (panelin varlığını doğrulamamak
68
+ * için). Oturum düştüyse sayfa yeniden yüklenir ve giriş formuna dönülür;
69
+ * arka planda dönmeye devam eden bir yoklama, IP'yi kendi kendine
70
+ * yasaklatırdı.
71
+ *
72
+ * @param {Response} response
73
+ * @returns {boolean} İstek işlenebilir mi.
74
+ */
75
+ function alive(response) {
76
+ if (response.status !== 404) return true;
77
+
78
+ stopPolling();
79
+ location.reload();
80
+ return false;
81
+ }
82
+
83
+ async function load() {
84
+ if (busy) return;
85
+ busy = true;
86
+
87
+ try {
88
+ const response = await fetch(`data?q=${encodeURIComponent(query)}`, {
89
+ headers: { Accept: "application/json" },
90
+ });
91
+ if (!alive(response)) return;
92
+ if (!response.ok) return;
93
+
94
+ latest = await response.json();
95
+ render();
96
+ } catch {
97
+ // Sunucu yeniden başlıyor olabilir; bir sonraki tur dener.
98
+ } finally {
99
+ busy = false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * @param {Record<string, unknown>} body
105
+ * @returns {Promise<void>}
106
+ */
107
+ async function act(body) {
108
+ const response = await fetch("action", {
109
+ method: "POST",
110
+ headers: {
111
+ "Content-Type": "application/json",
112
+ // Panelin CSRF freni: çapraz siteden gönderilemeyen bir başlık.
113
+ "X-JSkelet-Cache-Panel": "1",
114
+ },
115
+ body: JSON.stringify(body),
116
+ });
117
+
118
+ if (!alive(response)) return;
119
+
120
+ const result = await response.json().catch(() => ({ ok: false, message: "Failed" }));
121
+ toast(result.message ?? (result.ok ? "Done" : "Failed"), result.ok !== false);
122
+ await load();
123
+ }
124
+
125
+ /* ------------------------------------------------------------------- çizim */
126
+
127
+ function render() {
128
+ if (!latest) return;
129
+
130
+ const { process: proc, html, data, redis, prewarm } = latest;
131
+
132
+ $("env").textContent = proc.env;
133
+ $("pid").textContent = `pid ${proc.pid}`;
134
+ $("uptime").textContent = `up ${formatDuration(proc.uptime)}`;
135
+ $("memory").textContent = `rss ${formatBytes(proc.memory.rss)}`;
136
+
137
+ $("html-size").textContent = html.size.toLocaleString();
138
+ $("html-sub").textContent = `limit ${html.maxEntries.toLocaleString()}`;
139
+ $("html-bytes").textContent = formatBytes(html.bytes);
140
+ $("html-stale").textContent = `${html.stale} stale`;
141
+
142
+ $("data-size").textContent = data.size.toLocaleString();
143
+ $("data-sub").textContent = `${data.stale} stale · limit ${data.maxEntries.toLocaleString()}`;
144
+
145
+ const redisState = !redis.enabled
146
+ ? "off"
147
+ : redis.bypassed
148
+ ? "bypassed"
149
+ : redis.connected
150
+ ? "connected"
151
+ : "disconnected";
152
+
153
+ $("redis-state").textContent = redisState;
154
+ $("redis-sub").textContent = redis.enabled
155
+ ? `${redis.errors} errors · ${redis.keyPrefix || "no prefix"}`
156
+ : "cache.redis.enabled is false";
157
+
158
+ const done = prewarm.done ?? 0;
159
+ const total = prewarm.total ?? 0;
160
+ $("prewarm-state").textContent = prewarm.active ? `${done}/${total}` : "idle";
161
+ $("prewarm-sub").textContent = prewarm.active
162
+ ? "round in progress"
163
+ : total
164
+ ? `last round: ${done}/${total}`
165
+ : "no round yet";
166
+
167
+ renderTable();
168
+ }
169
+
170
+ function renderTable() {
171
+ const source = tab === "html" ? latest.html : latest.data;
172
+ const thead = $("thead");
173
+ const tbody = $("tbody");
174
+
175
+ $("listed").textContent =
176
+ source.matched > source.entries.length
177
+ ? `${source.entries.length} of ${source.matched} shown`
178
+ : `${source.matched} shown`;
179
+
180
+ thead.innerHTML =
181
+ tab === "html"
182
+ ? `<tr><th>Key</th><th>State</th><th class="num">Size</th><th class="num">Status</th><th class="num">Expires</th><th class="num">Deps</th><th>Encodings</th><th></th></tr>`
183
+ : `<tr><th>Key</th><th>State</th><th class="num">Expires</th><th></th></tr>`;
184
+
185
+ tbody.replaceChildren(
186
+ ...source.entries.map((/** @type {any} */ entry) => row(entry)),
187
+ );
188
+
189
+ $("empty").hidden = source.entries.length > 0;
190
+ }
191
+
192
+ /**
193
+ * @param {any} entry
194
+ * @returns {HTMLTableRowElement}
195
+ */
196
+ function row(entry) {
197
+ const tr = document.createElement("tr");
198
+
199
+ const key = document.createElement("td");
200
+ key.className = "key mono";
201
+ key.textContent = entry.key;
202
+ tr.append(key);
203
+
204
+ const state = document.createElement("td");
205
+ const tag = document.createElement("span");
206
+ tag.className = entry.stale ? "tag stale" : "tag fresh";
207
+ tag.textContent = entry.stale ? "stale" : "fresh";
208
+ state.append(tag);
209
+ tr.append(state);
210
+
211
+ if (tab === "html") {
212
+ tr.append(
213
+ cell(formatBytes(entry.bytes), "num"),
214
+ cell(String(entry.status), "num"),
215
+ cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"),
216
+ cell(String(entry.deps), "num"),
217
+ cell(entry.encodings.length ? entry.encodings.join(", ") : "—"),
218
+ );
219
+ } else {
220
+ tr.append(cell(entry.stale ? "—" : formatDuration(entry.expiresIn), "num"));
221
+ }
222
+
223
+ const actions = document.createElement("td");
224
+ actions.className = "actions";
225
+
226
+ const drop = document.createElement("button");
227
+ drop.className = "tiny danger";
228
+ drop.textContent = "drop";
229
+ drop.addEventListener("click", () => {
230
+ void act({ type: tab === "html" ? "html:drop" : "data:drop", key: entry.key });
231
+ });
232
+
233
+ actions.append(drop);
234
+ tr.append(actions);
235
+
236
+ return tr;
237
+ }
238
+
239
+ /**
240
+ * @param {string} text
241
+ * @param {string} [className]
242
+ * @returns {HTMLTableCellElement}
243
+ */
244
+ function cell(text, className) {
245
+ const td = document.createElement("td");
246
+ if (className) td.className = className;
247
+ td.textContent = text;
248
+ return td;
249
+ }
250
+
251
+ /* ------------------------------------------------------------------ olaylar */
252
+
253
+ function startPolling() {
254
+ stopPolling();
255
+ timer = setInterval(load, REFRESH_MS);
256
+ }
257
+
258
+ function stopPolling() {
259
+ if (timer !== null) clearInterval(timer);
260
+ timer = null;
261
+ }
262
+
263
+ $("auto").addEventListener("change", (event) => {
264
+ if (/** @type {HTMLInputElement} */ (event.target).checked) startPolling();
265
+ else stopPolling();
266
+ });
267
+
268
+ $("refresh").addEventListener("click", () => void load());
269
+
270
+ $("logout").addEventListener("click", async () => {
271
+ stopPolling();
272
+ await fetch("logout", { method: "POST" });
273
+ location.reload();
274
+ });
275
+
276
+ // Arama her tuşta sunucuya gitmez: on binlerce anahtarlı bir önbellekte
277
+ // filtreleme sunucuda yapılıyor.
278
+ let searchTimer = 0;
279
+ $("search").addEventListener("input", (event) => {
280
+ query = /** @type {HTMLInputElement} */ (event.target).value;
281
+ clearTimeout(searchTimer);
282
+ searchTimer = setTimeout(load, 200);
283
+ });
284
+
285
+ for (const name of /** @type {const} */ (["html", "data"])) {
286
+ $(`tab-${name}`).addEventListener("click", () => {
287
+ tab = name;
288
+ $("tab-html").setAttribute("aria-selected", String(name === "html"));
289
+ $("tab-data").setAttribute("aria-selected", String(name === "data"));
290
+ renderTable();
291
+ });
292
+ }
293
+
294
+ $("invalidate").addEventListener("click", () => {
295
+ const target = $("target").value.trim();
296
+ if (!target) {
297
+ toast("Enter a path or pattern first.", false);
298
+ return;
299
+ }
300
+
301
+ void act({ type: "html:invalidate", target, hard: $("hard").checked });
302
+ });
303
+
304
+ $("clear-html").addEventListener("click", () => {
305
+ if (!confirm("Clear the entire HTML cache? Every page goes cold.")) return;
306
+ void act({ type: "html:clear" });
307
+ });
308
+
309
+ $("clear-data").addEventListener("click", () => {
310
+ const prefix = $("prefix").value.trim();
311
+ if (!prefix && !confirm("Clear every data entry? Upstream traffic will spike.")) {
312
+ return;
313
+ }
314
+
315
+ void act({ type: "data:clear", prefix });
316
+ });
317
+
318
+ $("prewarm").addEventListener("click", () => void act({ type: "prewarm" }));
319
+
320
+ $("redis-html").addEventListener("click", () =>
321
+ void act({ type: "redis:drop", kind: "html" }),
322
+ );
323
+
324
+ $("redis-data").addEventListener("click", () =>
325
+ void act({ type: "redis:drop", kind: "data" }),
326
+ );
327
+
328
+ // Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
329
+ // dakikalarca boşa istek atmasın.
330
+ document.addEventListener("visibilitychange", () => {
331
+ if (document.hidden) stopPolling();
332
+ else if ($("auto").checked) {
333
+ void load();
334
+ startPolling();
335
+ }
336
+ });
337
+
338
+ void load();
339
+ startPolling();
@@ -704,6 +704,21 @@ const CSS = `
704
704
  border-radius: 6px; padding: 1px 6px; font-family: ui-monospace, monospace;
705
705
  }
706
706
 
707
+ /* Yüklü sürüm ve kayıt defterindeki son sürüm. */
708
+ .ver {
709
+ display: inline-flex; align-items: center; gap: 6px;
710
+ font-size: 10.5px; font-weight: 600; color: var(--muted);
711
+ border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px;
712
+ font-family: ui-monospace, SFMono-Regular, monospace;
713
+ font-variant-numeric: tabular-nums;
714
+ }
715
+ .ver .cur { color: #dbe2ea; }
716
+ .ver .state { text-transform: uppercase; letter-spacing: .05em; font-size: 9.5px; }
717
+ .ver.latest .state { color: var(--good); }
718
+ .ver.outdated { border-color: rgba(251,191,36,.35); }
719
+ .ver.outdated .state { color: var(--mid); }
720
+ .ver .next { color: var(--mid); }
721
+
707
722
  .rail {
708
723
  display: flex; flex-direction: column; gap: 4px; padding: 14px 10px;
709
724
  border-inline-end: 1px solid var(--line); background: rgba(0,0,0,.22);
@@ -893,6 +908,7 @@ const SKELETON = `
893
908
  <div class="head">
894
909
  <img src="${BASE}/logo.png" alt="">
895
910
  <span class="title">JSkelet Dev</span>
911
+ <span class="ver" data-part="version" hidden></span>
896
912
  <span class="pill"><span class="dot"></span><span data-part="pill"></span></span>
897
913
  <span class="spacer"></span>
898
914
  <span class="kbd">Alt+D</span>
@@ -1291,6 +1307,7 @@ function serverTab() {
1291
1307
  ]
1292
1308
  : []),
1293
1309
  ])}
1310
+ ${upstreamSection()}
1294
1311
  <h4>Recent requests</h4>
1295
1312
  ${
1296
1313
  requests.length
@@ -1309,6 +1326,39 @@ function serverTab() {
1309
1326
  `;
1310
1327
  }
1311
1328
 
1329
+ /**
1330
+ * Upstream hız freninin durumu. Fren kapalıysa (varsayılan) hiçbir şey
1331
+ * basılmaz; açıkken 429 fırtınasında hızın nereye indiğini görmek, ayarı
1332
+ * körlemesine yapmamanın tek yolu.
1333
+ *
1334
+ * @returns {string}
1335
+ */
1336
+ function upstreamSection() {
1337
+ const hosts = serverStats?.upstream ?? [];
1338
+ if (!hosts.length) return "";
1339
+
1340
+ return `
1341
+ <h4>Upstream rate limit</h4>
1342
+ <div class="rows">${hosts
1343
+ .map((host) => {
1344
+ const throttled = host.rate < host.maxRate;
1345
+ const state = host.bypassed
1346
+ ? "bypassed"
1347
+ : host.blockedMs
1348
+ ? `waiting ${Math.ceil(host.blockedMs / 1000)} s`
1349
+ : `${host.active} in flight`;
1350
+
1351
+ return `<div class="row">
1352
+ <span class="status ${host.bypassed ? "bad" : throttled ? "warn" : ""}">${host.rate}/s</span>
1353
+ <span class="path">${escapeHtml(host.host)}</span>
1354
+ <span class="hint">${escapeHtml(state)}</span>
1355
+ <span class="${host.throttled ? "mid" : ""}">${host.throttled} × 429</span>
1356
+ </div>`;
1357
+ })
1358
+ .join("")}</div>
1359
+ `;
1360
+ }
1361
+
1312
1362
  function warmTab() {
1313
1363
  const warm = serverStats?.prewarm;
1314
1364
 
@@ -1647,6 +1697,40 @@ function paintPrewarm(root) {
1647
1697
  if (!warm.active) setTimeout(render, WARM_LINGER_MS);
1648
1698
  }
1649
1699
 
1700
+ /**
1701
+ * Başlıktaki sürüm göstergesi: solda yüklü sürüm, sağda kayıt defterindeki
1702
+ * durum. Kontrol kapalıysa ya da kayıt defterine ulaşılamadıysa (`latest`
1703
+ * boş) yalnızca yüklü sürüm yazılır; "bilinmiyor" etiketi gürültü olurdu.
1704
+ *
1705
+ * @param {ShadowRoot} root
1706
+ * @param {{ current: string, latest: string | null, outdated: boolean } | undefined} version
1707
+ */
1708
+ function paintVersion(root, version) {
1709
+ const node = root.querySelector("[data-part='version']");
1710
+
1711
+ if (!version?.current) {
1712
+ node.hidden = true;
1713
+ return;
1714
+ }
1715
+
1716
+ const { current, latest, outdated } = version;
1717
+ node.hidden = false;
1718
+ node.className = `ver ${latest ? (outdated ? "outdated" : "latest") : ""}`;
1719
+ node.title = latest
1720
+ ? outdated
1721
+ ? `Installed ${current}, npm has ${latest}. Run: npm install jskelet@latest`
1722
+ : `Installed ${current} — up to date with npm`
1723
+ : `Installed ${current}. The npm registry could not be reached.`;
1724
+
1725
+ node.innerHTML =
1726
+ `<span class="cur">v${escapeHtml(current)}</span>` +
1727
+ (latest
1728
+ ? outdated
1729
+ ? `<span class="state">outdated</span><span class="next">→ v${escapeHtml(latest)}</span>`
1730
+ : `<span class="state">latest</span>`
1731
+ : "");
1732
+ }
1733
+
1650
1734
  function paint() {
1651
1735
  const root = ensureRoot();
1652
1736
 
@@ -1690,6 +1774,7 @@ function paint() {
1690
1774
  chip.textContent = String(errorCount);
1691
1775
 
1692
1776
  const version = serverStats?.version;
1777
+ paintVersion(root, version);
1693
1778
  const versionChip = root.querySelector("[data-part='version-chip']");
1694
1779
  versionChip.hidden = !version?.outdated;
1695
1780
  if (version?.outdated) versionChip.title = `JSkelet ${version.latest} is available`;
@@ -228,6 +228,19 @@ function overview() {
228
228
  tone: apiErrors ? "bad" : "good",
229
229
  sub: apiErrors ? `${apiErrors} errors` : "no errors",
230
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
+ },
231
244
  {
232
245
  label: "Prewarm",
233
246
  value: data.prewarm?.total