jskelet 0.2.0 → 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.
- package/CHANGELOG.md +62 -6
- package/docs/06-cache.md +202 -9
- package/docs/07-yapilandirma.md +70 -2
- package/docs/en/06-caching.md +209 -10
- package/docs/en/07-configuration.md +72 -2
- package/package.json +1 -1
- package/src/client/cache-panel/login.html +63 -0
- package/src/client/cache-panel/panel.css +448 -0
- package/src/client/cache-panel/panel.html +133 -0
- package/src/client/cache-panel/panel.js +339 -0
- package/src/client/devtools/overlay.js +85 -0
- package/src/client/devtools/report.js +13 -0
- package/src/config/defaults.js +67 -2
- package/src/config/index.js +98 -1
- package/src/index.js +5 -0
- package/src/server/cache-panel.js +505 -0
- package/src/server/create-app.js +16 -0
- package/src/server/data-cache.js +92 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/html-cache.js +36 -0
- package/src/server/prewarm.js +128 -24
- package/src/server/render.js +24 -14
- package/src/server/upstream-limiter.js +376 -0
- package/src/server/upstream-tracking.js +25 -0
|
@@ -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
|