jskelet 0.2.4 → 0.3.0
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 +403 -375
- package/docs/02-mimari.md +9 -3
- package/docs/03-routing.md +6 -0
- package/docs/06-cache.md +34 -12
- package/docs/07-yapilandirma.md +44 -21
- package/docs/11-tasima.md +1 -0
- package/docs/en/02-architecture.md +9 -3
- package/docs/en/03-routing.md +6 -0
- package/docs/en/06-caching.md +71 -72
- package/docs/en/07-configuration.md +44 -21
- package/docs/en/11-migration.md +1 -0
- package/package.json +1 -1
- package/src/client/{cache-panel → admin}/i18n.js +94 -8
- package/src/client/{cache-panel → admin}/login.html +2 -2
- package/src/client/{cache-panel → admin}/panel.css +49 -1
- package/src/client/admin/panel.html +486 -0
- package/src/client/{cache-panel → admin}/panel.js +333 -6
- package/src/config/defaults.js +16 -7
- package/src/config/index.js +38 -22
- package/src/log.mjs +58 -0
- package/src/server/admin/actions.js +229 -0
- package/src/server/admin/auth.js +125 -0
- package/src/server/admin/event-log.js +151 -0
- package/src/server/admin/gate.js +209 -0
- package/src/server/admin/inventory.js +188 -0
- package/src/server/admin/mount.js +56 -0
- package/src/server/admin/router.js +216 -0
- package/src/server/admin/snapshot.js +126 -0
- package/src/server/cloudflare.js +21 -9
- package/src/server/create-app.js +10 -6
- package/src/server/middleware/trailing-slash.js +53 -0
- package/src/client/cache-panel/panel.html +0 -308
- package/src/server/cache-panel.js +0 -759
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Admin panelinin istemci tarafı.
|
|
3
3
|
*
|
|
4
4
|
* Build hattından geçmez, doğrudan diskten servis edilir: panelin açık olduğu
|
|
5
5
|
* bir kurulumda `jskelet build` hiç koşmamış olabilir ve panel yine çalışmalı.
|
|
6
6
|
* Bu yüzden burada import edilen bir framework modülü yok.
|
|
7
7
|
*
|
|
8
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.
|
|
9
|
+
* süre açık kalıyor, kalıcı bir kanal açmanın karşılığı yok. Log sayfası
|
|
10
|
+
* ayrıca SSE kullanır.
|
|
10
11
|
*
|
|
11
12
|
* Bütün görünen metin `i18n.js` üzerinden geçiyor; sunucudan gelen cevaplar da
|
|
12
13
|
* metin değil `code` taşıyor, yani dil değişimi tek yerden hallediliyor.
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
import { applyTranslations, languageSelect, t } from "./i18n.js";
|
|
15
16
|
|
|
16
17
|
const REFRESH_MS = 3000;
|
|
18
|
+
const PAGES = new Set(["overview", "cache", "routes", "views", "logs", "system"]);
|
|
17
19
|
|
|
18
20
|
const $ = (/** @type {string} */ id) => document.getElementById(id);
|
|
19
21
|
|
|
@@ -25,6 +27,17 @@ let latest = null;
|
|
|
25
27
|
/** @type {number | null} */
|
|
26
28
|
let timer = null;
|
|
27
29
|
let busy = false;
|
|
30
|
+
/** @type {string} */
|
|
31
|
+
let page = "overview";
|
|
32
|
+
/** @type {any} */
|
|
33
|
+
let routesPayload = null;
|
|
34
|
+
/** @type {any} */
|
|
35
|
+
let viewsPayload = null;
|
|
36
|
+
/** @type {any[]} */
|
|
37
|
+
let logBuffer = [];
|
|
38
|
+
/** @type {EventSource | null} */
|
|
39
|
+
let logSource = null;
|
|
40
|
+
let logsPaused = false;
|
|
28
41
|
|
|
29
42
|
/* ------------------------------------------------------------ biçimlendirme */
|
|
30
43
|
|
|
@@ -118,7 +131,7 @@ async function act(body) {
|
|
|
118
131
|
headers: {
|
|
119
132
|
"Content-Type": "application/json",
|
|
120
133
|
// Panelin CSRF freni: çapraz siteden gönderilemeyen bir başlık.
|
|
121
|
-
"X-JSkelet-
|
|
134
|
+
"X-JSkelet-Admin": "1",
|
|
122
135
|
},
|
|
123
136
|
body: JSON.stringify(body),
|
|
124
137
|
});
|
|
@@ -206,6 +219,7 @@ function render() {
|
|
|
206
219
|
|
|
207
220
|
renderRedis(redis, redisState);
|
|
208
221
|
renderHost(host, proc, redis);
|
|
222
|
+
renderUpstream(latest.upstream);
|
|
209
223
|
|
|
210
224
|
const done = prewarm.done ?? 0;
|
|
211
225
|
const total = prewarm.total ?? 0;
|
|
@@ -219,6 +233,49 @@ function render() {
|
|
|
219
233
|
renderTable();
|
|
220
234
|
}
|
|
221
235
|
|
|
236
|
+
/**
|
|
237
|
+
* @param {any} upstream
|
|
238
|
+
*/
|
|
239
|
+
function renderUpstream(upstream) {
|
|
240
|
+
const tbody = $("upstream-tbody");
|
|
241
|
+
const empty = $("upstream-empty");
|
|
242
|
+
const heading = $("upstream-heading");
|
|
243
|
+
if (!tbody || !empty || !heading) return;
|
|
244
|
+
|
|
245
|
+
const hosts = Array.isArray(upstream) ? upstream : [];
|
|
246
|
+
heading.textContent = String(hosts.length);
|
|
247
|
+
tbody.replaceChildren();
|
|
248
|
+
|
|
249
|
+
if (!hosts.length) {
|
|
250
|
+
empty.hidden = false;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
empty.hidden = true;
|
|
255
|
+
for (const info of hosts) {
|
|
256
|
+
const row = document.createElement("tr");
|
|
257
|
+
const state = info?.bypassed
|
|
258
|
+
? "bypassed"
|
|
259
|
+
: info?.blockedMs > 0
|
|
260
|
+
? "blocked"
|
|
261
|
+
: `${info?.active ?? 0} active`;
|
|
262
|
+
row.innerHTML = `<td class="mono">${escapeHtml(String(info.host ?? ""))}</td><td class="mono">${escapeHtml(String(info.rate ?? "—"))}</td><td class="mono">${escapeHtml(String(state))}</td>`;
|
|
263
|
+
tbody.append(row);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {string} value
|
|
269
|
+
* @returns {string}
|
|
270
|
+
*/
|
|
271
|
+
function escapeHtml(value) {
|
|
272
|
+
return value
|
|
273
|
+
.replaceAll("&", "&")
|
|
274
|
+
.replaceAll("<", "<")
|
|
275
|
+
.replaceAll(">", ">")
|
|
276
|
+
.replaceAll('"', """);
|
|
277
|
+
}
|
|
278
|
+
|
|
222
279
|
/* ------------------------------------------------------------- cloudflare */
|
|
223
280
|
|
|
224
281
|
/** @type {any} */
|
|
@@ -352,7 +409,7 @@ async function loadCloudflareAnalytics() {
|
|
|
352
409
|
method: "POST",
|
|
353
410
|
headers: {
|
|
354
411
|
"Content-Type": "application/json",
|
|
355
|
-
"X-JSkelet-
|
|
412
|
+
"X-JSkelet-Admin": "1",
|
|
356
413
|
},
|
|
357
414
|
body: JSON.stringify({ path }),
|
|
358
415
|
});
|
|
@@ -888,11 +945,273 @@ function flip(value) {
|
|
|
888
945
|
// Sekme arkaya alındığında yoklama durur: panel açık kalmış bir sekmede
|
|
889
946
|
// dakikalarca boşa istek atmasın.
|
|
890
947
|
document.addEventListener("visibilitychange", () => {
|
|
891
|
-
if (document.hidden)
|
|
892
|
-
|
|
948
|
+
if (document.hidden) {
|
|
949
|
+
stopPolling();
|
|
950
|
+
stopLogStream();
|
|
951
|
+
} else if ($("auto").checked) {
|
|
893
952
|
void load();
|
|
894
953
|
startPolling();
|
|
954
|
+
if (page === "logs") startLogStream();
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
/* -------------------------------------------------------------- sayfalar */
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* URL yolunun son segmentinden sayfa adı.
|
|
962
|
+
* @returns {string}
|
|
963
|
+
*/
|
|
964
|
+
function pageFromLocation() {
|
|
965
|
+
const parts = location.pathname.replace(/\/+$/, "").split("/");
|
|
966
|
+
const last = parts[parts.length - 1] || "";
|
|
967
|
+
if (PAGES.has(last)) return last;
|
|
968
|
+
return "overview";
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* @param {string} next
|
|
973
|
+
* @param {{ push?: boolean }} [options]
|
|
974
|
+
*/
|
|
975
|
+
function showPage(next, options = {}) {
|
|
976
|
+
const name = PAGES.has(next) ? next : "overview";
|
|
977
|
+
page = name;
|
|
978
|
+
|
|
979
|
+
for (const node of document.querySelectorAll(".page")) {
|
|
980
|
+
const el = /** @type {HTMLElement} */ (node);
|
|
981
|
+
el.hidden = el.dataset.page !== name;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
for (const link of document.querySelectorAll("#nav a")) {
|
|
985
|
+
const a = /** @type {HTMLAnchorElement} */ (link);
|
|
986
|
+
if (a.dataset.page === name) a.setAttribute("aria-current", "page");
|
|
987
|
+
else a.removeAttribute("aria-current");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const sse = $("sse-status");
|
|
991
|
+
if (sse) sse.hidden = name !== "logs";
|
|
992
|
+
|
|
993
|
+
if (options.push !== false) {
|
|
994
|
+
const target = name === "overview" ? "./" : name;
|
|
995
|
+
history.pushState({ page: name }, "", target);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (name === "routes") void loadRoutes();
|
|
999
|
+
if (name === "views") void loadViews();
|
|
1000
|
+
if (name === "logs") startLogStream();
|
|
1001
|
+
else stopLogStream();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function loadRoutes() {
|
|
1005
|
+
const response = await fetch("api/routes");
|
|
1006
|
+
if (!alive(response)) return;
|
|
1007
|
+
if (!response.ok) return;
|
|
1008
|
+
routesPayload = await response.json();
|
|
1009
|
+
renderRoutes();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function loadViews() {
|
|
1013
|
+
const response = await fetch("api/views");
|
|
1014
|
+
if (!alive(response)) return;
|
|
1015
|
+
if (!response.ok) return;
|
|
1016
|
+
viewsPayload = await response.json();
|
|
1017
|
+
renderViews();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function renderRoutes() {
|
|
1021
|
+
if (!routesPayload) return;
|
|
1022
|
+
|
|
1023
|
+
const routes = routesPayload.routes ?? [];
|
|
1024
|
+
const modules = routesPayload.modules ?? [];
|
|
1025
|
+
const activity = routesPayload.activity ?? {};
|
|
1026
|
+
|
|
1027
|
+
$("routes-count").textContent = String(routes.length);
|
|
1028
|
+
$("modules-count").textContent = String(modules.length);
|
|
1029
|
+
|
|
1030
|
+
const tbody = $("routes-tbody");
|
|
1031
|
+
const empty = $("routes-empty");
|
|
1032
|
+
tbody.replaceChildren();
|
|
1033
|
+
|
|
1034
|
+
if (!routes.length) {
|
|
1035
|
+
empty.hidden = false;
|
|
1036
|
+
} else {
|
|
1037
|
+
empty.hidden = true;
|
|
1038
|
+
for (const route of routes) {
|
|
1039
|
+
const key = route.path;
|
|
1040
|
+
const act = activity[key] ?? null;
|
|
1041
|
+
const row = document.createElement("tr");
|
|
1042
|
+
row.innerHTML = `
|
|
1043
|
+
<td class="mono">${escapeHtml(route.method)}</td>
|
|
1044
|
+
<td class="mono"><button type="button" class="linkish" data-path="${escapeHtml(route.path)}">${escapeHtml(route.path)}</button></td>
|
|
1045
|
+
<td class="mono">${act ? act.status : "—"}</td>
|
|
1046
|
+
<td class="mono">${act ? Math.round(act.ms) : "—"}</td>
|
|
1047
|
+
<td class="mono">${act ? act.count : "—"}</td>`;
|
|
1048
|
+
tbody.append(row);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
tbody.querySelectorAll("button[data-path]").forEach((button) => {
|
|
1052
|
+
button.addEventListener("click", () => {
|
|
1053
|
+
const pathFilter = /** @type {HTMLElement} */ (button).dataset.path ?? "";
|
|
1054
|
+
$("logs-path").value = pathFilter;
|
|
1055
|
+
showPage("logs");
|
|
1056
|
+
renderLogs();
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const modulesBody = $("modules-tbody");
|
|
1062
|
+
modulesBody.replaceChildren();
|
|
1063
|
+
for (const mod of modules) {
|
|
1064
|
+
const row = document.createElement("tr");
|
|
1065
|
+
row.innerHTML = `<td class="mono">${escapeHtml(mod.relative)}</td>`;
|
|
1066
|
+
modulesBody.append(row);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function renderViews() {
|
|
1071
|
+
if (!viewsPayload) return;
|
|
1072
|
+
|
|
1073
|
+
const term = ($("views-search")?.value ?? "").trim().toLowerCase();
|
|
1074
|
+
const views = (viewsPayload.views ?? []).filter(
|
|
1075
|
+
(/** @type {{ relative: string }} */ view) =>
|
|
1076
|
+
!term || view.relative.toLowerCase().includes(term),
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
$("views-count").textContent = String(views.length);
|
|
1080
|
+
const tbody = $("views-tbody");
|
|
1081
|
+
const empty = $("views-empty");
|
|
1082
|
+
tbody.replaceChildren();
|
|
1083
|
+
|
|
1084
|
+
if (!views.length) {
|
|
1085
|
+
empty.hidden = false;
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
empty.hidden = true;
|
|
1090
|
+
for (const view of views) {
|
|
1091
|
+
const row = document.createElement("tr");
|
|
1092
|
+
row.innerHTML = `<td class="mono">${escapeHtml(view.relative)}</td><td>${escapeHtml(view.kind)}</td>`;
|
|
1093
|
+
tbody.append(row);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function startLogStream() {
|
|
1098
|
+
if (logSource) return;
|
|
1099
|
+
|
|
1100
|
+
const chip = $("sse-status");
|
|
1101
|
+
if (chip) {
|
|
1102
|
+
chip.hidden = false;
|
|
1103
|
+
chip.textContent = t("chip.sse.live");
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
logSource = new EventSource("api/logs/stream");
|
|
1107
|
+
logSource.onmessage = (event) => {
|
|
1108
|
+
try {
|
|
1109
|
+
const entry = JSON.parse(event.data);
|
|
1110
|
+
if (!logsPaused) {
|
|
1111
|
+
logBuffer.push(entry);
|
|
1112
|
+
if (logBuffer.length > 1000) logBuffer.shift();
|
|
1113
|
+
renderLogs();
|
|
1114
|
+
}
|
|
1115
|
+
} catch {
|
|
1116
|
+
// bozuk çerçeve yok sayılır
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
logSource.onerror = () => {
|
|
1120
|
+
if (chip) chip.textContent = t("chip.sse.error");
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function stopLogStream() {
|
|
1125
|
+
if (logSource) {
|
|
1126
|
+
logSource.close();
|
|
1127
|
+
logSource = null;
|
|
1128
|
+
}
|
|
1129
|
+
const chip = $("sse-status");
|
|
1130
|
+
if (chip) {
|
|
1131
|
+
chip.textContent = t("chip.sse.off");
|
|
1132
|
+
chip.hidden = page !== "logs";
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function renderLogs() {
|
|
1137
|
+
const list = $("logs-list");
|
|
1138
|
+
const empty = $("logs-empty");
|
|
1139
|
+
const count = $("logs-count");
|
|
1140
|
+
if (!list || !empty || !count) return;
|
|
1141
|
+
|
|
1142
|
+
const q = ($("logs-q")?.value ?? "").trim().toLowerCase();
|
|
1143
|
+
const kind = $("logs-kind")?.value ?? "";
|
|
1144
|
+
const method = $("logs-method")?.value ?? "";
|
|
1145
|
+
const statusClass = $("logs-status")?.value ?? "";
|
|
1146
|
+
const cache = $("logs-cache")?.value ?? "";
|
|
1147
|
+
const pathFilter = ($("logs-path")?.value ?? "").trim().toLowerCase();
|
|
1148
|
+
|
|
1149
|
+
const filtered = logBuffer.filter((entry) => {
|
|
1150
|
+
if (kind && entry.kind !== kind) return false;
|
|
1151
|
+
if (method && entry.method !== method) return false;
|
|
1152
|
+
if (statusClass && entry.status != null) {
|
|
1153
|
+
if (String(Math.floor(Number(entry.status) / 100)) !== statusClass) return false;
|
|
1154
|
+
}
|
|
1155
|
+
if (cache && entry.cache !== cache) return false;
|
|
1156
|
+
if (pathFilter) {
|
|
1157
|
+
const hay = `${entry.path ?? ""} ${entry.route ?? ""} ${entry.url ?? ""}`.toLowerCase();
|
|
1158
|
+
if (!hay.includes(pathFilter)) return false;
|
|
1159
|
+
}
|
|
1160
|
+
if (q) {
|
|
1161
|
+
const hay = `${entry.url ?? ""} ${entry.message ?? ""} ${entry.scope ?? ""} ${entry.path ?? ""}`.toLowerCase();
|
|
1162
|
+
if (!hay.includes(q)) return false;
|
|
1163
|
+
}
|
|
1164
|
+
return true;
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
count.textContent = t("logs.shown", { shown: filtered.length, total: logBuffer.length });
|
|
1168
|
+
empty.hidden = filtered.length > 0;
|
|
1169
|
+
|
|
1170
|
+
const lines = filtered.slice(-300).map(formatLogLine);
|
|
1171
|
+
list.textContent = lines.join("\n");
|
|
1172
|
+
list.scrollTop = list.scrollHeight;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* @param {any} entry
|
|
1177
|
+
* @returns {string}
|
|
1178
|
+
*/
|
|
1179
|
+
function formatLogLine(entry) {
|
|
1180
|
+
const time = new Date(entry.at).toISOString().slice(11, 19);
|
|
1181
|
+
if (entry.kind === "http") {
|
|
1182
|
+
const cache = entry.cache ? ` ${entry.cache}` : "";
|
|
1183
|
+
const route = entry.route ? ` [${entry.route}]` : "";
|
|
1184
|
+
return `${time} ${String(entry.method).padEnd(6)} ${entry.status} ${Math.round(entry.ms)}ms${cache}${route} ${entry.url}`;
|
|
895
1185
|
}
|
|
1186
|
+
return `${time} ${entry.kind.padEnd(6)} ${entry.scope ?? ""} ${entry.message ?? ""}${entry.note ? ` ${entry.note}` : ""}`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
for (const link of document.querySelectorAll("#nav a")) {
|
|
1190
|
+
link.addEventListener("click", (event) => {
|
|
1191
|
+
event.preventDefault();
|
|
1192
|
+
const next = /** @type {HTMLElement} */ (link).dataset.page ?? "overview";
|
|
1193
|
+
showPage(next);
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
window.addEventListener("popstate", () => {
|
|
1198
|
+
showPage(pageFromLocation(), { push: false });
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
$("views-search")?.addEventListener("input", () => renderViews());
|
|
1202
|
+
|
|
1203
|
+
for (const id of ["logs-q", "logs-kind", "logs-method", "logs-status", "logs-cache", "logs-path"]) {
|
|
1204
|
+
$(id)?.addEventListener("input", () => renderLogs());
|
|
1205
|
+
$(id)?.addEventListener("change", () => renderLogs());
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
$("logs-pause")?.addEventListener("change", (event) => {
|
|
1209
|
+
logsPaused = /** @type {HTMLInputElement} */ (event.target).checked;
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
$("logs-clear")?.addEventListener("click", () => {
|
|
1213
|
+
logBuffer = [];
|
|
1214
|
+
renderLogs();
|
|
896
1215
|
});
|
|
897
1216
|
|
|
898
1217
|
/* -------------------------------------------------------------------- dil */
|
|
@@ -905,11 +1224,19 @@ $("language").append(
|
|
|
905
1224
|
if (latest) render();
|
|
906
1225
|
renderCloudflare();
|
|
907
1226
|
renderReport();
|
|
1227
|
+
renderRoutes();
|
|
1228
|
+
renderViews();
|
|
1229
|
+
renderLogs();
|
|
908
1230
|
}),
|
|
909
1231
|
);
|
|
910
1232
|
|
|
911
1233
|
applyTranslations();
|
|
912
1234
|
|
|
1235
|
+
const params = new URLSearchParams(location.search);
|
|
1236
|
+
if (params.get("path")) $("logs-path").value = params.get("path") ?? "";
|
|
1237
|
+
if (params.get("route")) $("logs-path").value = params.get("route") ?? "";
|
|
1238
|
+
|
|
1239
|
+
showPage(pageFromLocation(), { push: false });
|
|
913
1240
|
void load();
|
|
914
1241
|
void loadCloudflare();
|
|
915
1242
|
startPolling();
|
package/src/config/defaults.js
CHANGED
|
@@ -192,27 +192,36 @@ export const DEFAULT_REDIS = {
|
|
|
192
192
|
};
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
|
-
*
|
|
195
|
+
* Framework yönetim paneli (`/_jskelet/admin`).
|
|
196
196
|
*
|
|
197
197
|
* `enabled` varsayılan olarak **kapalı** ve ortama bakmaz: panel açıldığında
|
|
198
198
|
* production'da da çalışır, ama açılması bilinçli bir karar olmalı. Kapalıyken
|
|
199
199
|
* router hiç mount edilmez — yolun kendisi de yok, yani 404 dönen bir uç bile
|
|
200
200
|
* ortaya çıkmaz.
|
|
201
201
|
*
|
|
202
|
-
* Şifre her süreç başlangıcında yeniden üretilir (bkz.
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* alanı yok.
|
|
202
|
+
* Şifre her süreç başlangıcında yeniden üretilir (bkz. `src/server/admin/`):
|
|
203
|
+
* panelin ömrü sürecin ömrü kadardır ve bir deploy eski erişimi otomatik
|
|
204
|
+
* olarak iptal eder. Bu yüzden config'te şifre alanı yok.
|
|
206
205
|
*/
|
|
207
|
-
export const
|
|
206
|
+
export const DEFAULT_ADMIN = {
|
|
208
207
|
enabled: false,
|
|
209
|
-
basePath: "/_jskelet/
|
|
208
|
+
basePath: "/_jskelet/admin",
|
|
209
|
+
/**
|
|
210
|
+
* Boş dizi = IP kısıtı yok. Exact IP veya CIDR (`10.0.0.0/8`); listede
|
|
211
|
+
* olmayan her istek 404 (login dahil).
|
|
212
|
+
* @type {string[]}
|
|
213
|
+
*/
|
|
214
|
+
allowIps: [],
|
|
215
|
+
/** Bilinen crawler / bot UA'ları 404 ile reddedilsin mi. */
|
|
216
|
+
blockBots: true,
|
|
210
217
|
/** Kaç başarısız denemeden sonra IP yasaklanır. */
|
|
211
218
|
banAttempts: 3,
|
|
212
219
|
/** Yasağın süresi. */
|
|
213
220
|
banHours: 24,
|
|
214
221
|
/** Oturumun ömrü; süreç yeniden başladığında zaten sıfırlanır. */
|
|
215
222
|
sessionHours: 12,
|
|
223
|
+
/** Canlı log ring boyutu (HTTP + framework olayları). */
|
|
224
|
+
logSize: 500,
|
|
216
225
|
};
|
|
217
226
|
|
|
218
227
|
/**
|
package/src/config/index.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
|
|
18
18
|
* cache() → { html?: { [source]: saniye },
|
|
19
19
|
* query?: { [source]: string[] | true }, maxEntries?: number,
|
|
20
|
-
* data?: {...}, redis?: {...}, prewarm?: {...}
|
|
21
|
-
*
|
|
20
|
+
* data?: {...}, redis?: {...}, prewarm?: {...} }
|
|
21
|
+
* admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
|
|
22
22
|
*
|
|
23
23
|
* Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
|
|
24
24
|
* düz nesne olarak okunur.
|
|
@@ -29,8 +29,8 @@ import process from "node:process";
|
|
|
29
29
|
import { pathToFileURL } from "node:url";
|
|
30
30
|
import { compilePattern, matchPattern } from "./pattern.js";
|
|
31
31
|
import {
|
|
32
|
+
DEFAULT_ADMIN,
|
|
32
33
|
DEFAULT_BRAND,
|
|
33
|
-
DEFAULT_CACHE_PANEL,
|
|
34
34
|
DEFAULT_CLOUDFLARE,
|
|
35
35
|
DEFAULT_DATA_CACHE,
|
|
36
36
|
DEFAULT_DEV_GATE_BYPASS,
|
|
@@ -96,7 +96,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
96
96
|
* @property {{ attempts: number, delayMs: number }} transientRetry
|
|
97
97
|
* @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
|
|
98
98
|
* @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
|
|
99
|
-
* @property {typeof
|
|
99
|
+
* @property {typeof DEFAULT_ADMIN} admin Framework yönetim paneli.
|
|
100
100
|
* @property {typeof DEFAULT_CLOUDFLARE} cloudflare Cloudflare cache yüzeyi.
|
|
101
101
|
* @property {Record<string, unknown>} prewarm
|
|
102
102
|
* @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
|
|
@@ -104,6 +104,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
104
104
|
* @property {Record<string, Function>} hooks
|
|
105
105
|
* @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
|
|
106
106
|
* @property {string[] | null} routes Açık route modülü listesi.
|
|
107
|
+
* @property {boolean} trailingSlash URL'ler `/` ile bitsin mi (Next `trailingSlash`).
|
|
107
108
|
* @property {{ extensions: Set<string>, prefixes: string[] }} static
|
|
108
109
|
* @property {string[]} devGateBypass
|
|
109
110
|
* @property {string[]} preconnect
|
|
@@ -301,46 +302,58 @@ function normalizeUpstream(raw) {
|
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla açmanın bedeli,
|
|
308
|
-
* önbelleği boşaltabilen bir ucu internete koymak.
|
|
305
|
+
* Yönetim paneli. `enabled` yalnızca açıkça `true` verildiğinde ya da
|
|
306
|
+
* `JSKELET_ADMIN` ortam değişkeni ayarlandığında açılır: paneli yanlışlıkla
|
|
307
|
+
* açmanın bedeli, önbelleği boşaltabilen bir ucu internete koymak.
|
|
309
308
|
*
|
|
310
309
|
* Ortam değişkeni config'in **üstünde** duruyor, çünkü paneli genelde bir
|
|
311
310
|
* arıza sırasında tek seferlik açmak isteniyor ve o an config dosyasını
|
|
312
|
-
* değiştirip yeniden dağıtmak istenmiyor. `
|
|
313
|
-
*
|
|
311
|
+
* değiştirip yeniden dağıtmak istenmiyor. `JSKELET_ADMIN=0` aynı mantıkla
|
|
312
|
+
* config'te açık olan paneli kapatır.
|
|
314
313
|
*
|
|
315
314
|
* @param {unknown} raw
|
|
316
|
-
* @returns {typeof
|
|
315
|
+
* @returns {typeof DEFAULT_ADMIN}
|
|
317
316
|
*/
|
|
318
|
-
function
|
|
317
|
+
function normalizeAdmin(raw) {
|
|
319
318
|
const source = /** @type {Record<string, any>} */ (raw ?? {});
|
|
320
|
-
const env = process.env.
|
|
319
|
+
const env = process.env.JSKELET_ADMIN;
|
|
321
320
|
|
|
322
321
|
const basePath =
|
|
323
322
|
typeof source.basePath === "string" && source.basePath.startsWith("/")
|
|
324
323
|
? source.basePath.replace(/\/+$/, "")
|
|
325
|
-
:
|
|
324
|
+
: DEFAULT_ADMIN.basePath;
|
|
326
325
|
|
|
327
326
|
/** @param {string} key @param {number} min */
|
|
328
327
|
const positive = (key, min) => {
|
|
329
328
|
const value = Number(source[key]);
|
|
330
329
|
return Number.isFinite(value) && value >= min
|
|
331
330
|
? value
|
|
332
|
-
: /** @type {any} */ (
|
|
331
|
+
: /** @type {any} */ (DEFAULT_ADMIN)[key];
|
|
333
332
|
};
|
|
334
333
|
|
|
334
|
+
const allowIps = Array.isArray(source.allowIps)
|
|
335
|
+
? source.allowIps
|
|
336
|
+
.filter((entry) => typeof entry === "string" && entry.trim())
|
|
337
|
+
.map((entry) => entry.trim())
|
|
338
|
+
: [...DEFAULT_ADMIN.allowIps];
|
|
339
|
+
|
|
340
|
+
const logSize = Number(source.logSize);
|
|
341
|
+
|
|
335
342
|
return {
|
|
336
343
|
enabled:
|
|
337
344
|
env === undefined
|
|
338
345
|
? source.enabled === true
|
|
339
346
|
: env !== "0" && env !== "false" && env !== "",
|
|
340
|
-
basePath: basePath ||
|
|
347
|
+
basePath: basePath || DEFAULT_ADMIN.basePath,
|
|
348
|
+
allowIps,
|
|
349
|
+
blockBots: source.blockBots !== false,
|
|
341
350
|
banAttempts: Math.floor(positive("banAttempts", 1)),
|
|
342
351
|
banHours: positive("banHours", 0),
|
|
343
352
|
sessionHours: positive("sessionHours", 0),
|
|
353
|
+
logSize:
|
|
354
|
+
Number.isFinite(logSize) && logSize >= 50
|
|
355
|
+
? Math.min(5000, Math.floor(logSize))
|
|
356
|
+
: DEFAULT_ADMIN.logSize,
|
|
344
357
|
};
|
|
345
358
|
}
|
|
346
359
|
|
|
@@ -423,7 +436,6 @@ function normalizeQueryRules(raw) {
|
|
|
423
436
|
* transientRetry: { attempts: number, delayMs: number },
|
|
424
437
|
* redis: RedisConfig,
|
|
425
438
|
* upstream: typeof DEFAULT_UPSTREAM_LIMIT,
|
|
426
|
-
* cachePanel: typeof DEFAULT_CACHE_PANEL,
|
|
427
439
|
* cloudflare: typeof DEFAULT_CLOUDFLARE,
|
|
428
440
|
* prewarm: Record<string, unknown>,
|
|
429
441
|
* prewarmPriority: ResolvedConfig["prewarmPriority"] }}
|
|
@@ -464,7 +476,6 @@ function normalizeCache(raw) {
|
|
|
464
476
|
: { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
|
|
465
477
|
redis: normalizeRedis(raw?.redis),
|
|
466
478
|
upstream: normalizeUpstream(raw?.upstream),
|
|
467
|
-
cachePanel: normalizeCachePanel(raw?.panel),
|
|
468
479
|
cloudflare: normalizeCloudflare(raw?.cloudflare),
|
|
469
480
|
// Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
|
|
470
481
|
// ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
|
|
@@ -669,11 +680,12 @@ export async function loadConfig(options = {}) {
|
|
|
669
680
|
}
|
|
670
681
|
};
|
|
671
682
|
|
|
672
|
-
const [headers, redirects, rewrites, cache] = await Promise.all([
|
|
683
|
+
const [headers, redirects, rewrites, cache, admin] = await Promise.all([
|
|
673
684
|
section("headers"),
|
|
674
685
|
section("redirects"),
|
|
675
686
|
section("rewrites"),
|
|
676
687
|
section("cache"),
|
|
688
|
+
section("admin"),
|
|
677
689
|
]);
|
|
678
690
|
|
|
679
691
|
const {
|
|
@@ -686,7 +698,6 @@ export async function loadConfig(options = {}) {
|
|
|
686
698
|
transientRetry,
|
|
687
699
|
redis,
|
|
688
700
|
upstream,
|
|
689
|
-
cachePanel,
|
|
690
701
|
cloudflare,
|
|
691
702
|
prewarm,
|
|
692
703
|
prewarmPriority,
|
|
@@ -710,7 +721,7 @@ export async function loadConfig(options = {}) {
|
|
|
710
721
|
transientRetry,
|
|
711
722
|
redis,
|
|
712
723
|
upstream,
|
|
713
|
-
|
|
724
|
+
admin: normalizeAdmin(admin),
|
|
714
725
|
cloudflare,
|
|
715
726
|
prewarm,
|
|
716
727
|
prewarmPriority,
|
|
@@ -718,6 +729,11 @@ export async function loadConfig(options = {}) {
|
|
|
718
729
|
hooks: source.hooks ?? {},
|
|
719
730
|
layout: resolveLayout(dirs, source.layout),
|
|
720
731
|
routes: Array.isArray(source.routes) ? source.routes : null,
|
|
732
|
+
// Varsayılan kapalı: açıkken `/hakkinda` → 308 `/hakkinda/` ve kanonik
|
|
733
|
+
// yanıt 200'dir. Kapalıyken slash dayatılmaz — Express'in non-strict
|
|
734
|
+
// eşleşmesi her iki biçimi de 200 ile servis eder (Next'in varsayılan
|
|
735
|
+
// "slash'ı kırp" davranışından bilinçli fark).
|
|
736
|
+
trailingSlash: source.trailingSlash === true,
|
|
721
737
|
static: {
|
|
722
738
|
extensions: new Set(source.static?.extensions ?? DEFAULT_STATIC.extensions),
|
|
723
739
|
prefixes: source.static?.prefixes ?? DEFAULT_STATIC.prefixes,
|
package/src/log.mjs
CHANGED
|
@@ -253,6 +253,14 @@ export function event({ symbol = symbols.ok, scope, message, note, time = null }
|
|
|
253
253
|
`${message}${note ? ` ${c.dim(note)}` : ""}${padding}` +
|
|
254
254
|
`${c.gray((time == null ? "" : ms(time)).padStart(TIME))}\n`,
|
|
255
255
|
);
|
|
256
|
+
|
|
257
|
+
emitLog({
|
|
258
|
+
kind: "event",
|
|
259
|
+
scope,
|
|
260
|
+
message,
|
|
261
|
+
note: note ?? null,
|
|
262
|
+
ms: time ?? null,
|
|
263
|
+
});
|
|
256
264
|
}
|
|
257
265
|
|
|
258
266
|
/**
|
|
@@ -271,6 +279,15 @@ export function http(info) {
|
|
|
271
279
|
`${tint(String(info.status))} ${c.gray(ms(info.ms).padStart(TIME))}` +
|
|
272
280
|
`${info.cache === "HIT" ? ` ${c.dim("cached")}` : ""}\n`,
|
|
273
281
|
);
|
|
282
|
+
|
|
283
|
+
emitLog({
|
|
284
|
+
kind: "http",
|
|
285
|
+
method: info.method,
|
|
286
|
+
url: info.url,
|
|
287
|
+
status: info.status,
|
|
288
|
+
ms: info.ms,
|
|
289
|
+
cache: info.cache ?? null,
|
|
290
|
+
});
|
|
274
291
|
}
|
|
275
292
|
|
|
276
293
|
/**
|
|
@@ -337,6 +354,14 @@ export function errorBox({ title, name, message, lines = [] }) {
|
|
|
337
354
|
}
|
|
338
355
|
line("");
|
|
339
356
|
write(`${c.red(bottom)}\n\n`);
|
|
357
|
+
|
|
358
|
+
emitLog({
|
|
359
|
+
kind: "error",
|
|
360
|
+
scope: title,
|
|
361
|
+
message: `${name}: ${message}`,
|
|
362
|
+
note: lines.length ? lines.join(" · ") : null,
|
|
363
|
+
ms: null,
|
|
364
|
+
});
|
|
340
365
|
}
|
|
341
366
|
|
|
342
367
|
/**
|
|
@@ -361,3 +386,36 @@ function wrap(text, max) {
|
|
|
361
386
|
if (current) out.push(current);
|
|
362
387
|
return out.length ? out : [""];
|
|
363
388
|
}
|
|
389
|
+
|
|
390
|
+
/* -------------------------------------------------------------- aboneler */
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Admin paneli gibi dinleyiciler. Abone yokken `emitLog` boş Set üzerinde
|
|
394
|
+
* döner — stdout yoluna maliyet eklemez.
|
|
395
|
+
*
|
|
396
|
+
* @type {Set<(entry: Record<string, unknown>) => void>}
|
|
397
|
+
*/
|
|
398
|
+
const listeners = new Set();
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* @param {(entry: Record<string, unknown>) => void} listener
|
|
402
|
+
* @returns {() => void} Aboneliği iptal eden fonksiyon.
|
|
403
|
+
*/
|
|
404
|
+
export function subscribe(listener) {
|
|
405
|
+
listeners.add(listener);
|
|
406
|
+
return () => listeners.delete(listener);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* @param {Record<string, unknown>} entry
|
|
411
|
+
*/
|
|
412
|
+
function emitLog(entry) {
|
|
413
|
+
if (!listeners.size) return;
|
|
414
|
+
for (const listener of listeners) {
|
|
415
|
+
try {
|
|
416
|
+
listener(entry);
|
|
417
|
+
} catch {
|
|
418
|
+
// Dinleyici paneli düşürmesin.
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|