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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route ve view envanteri — admin paneline salt okunur döküm.
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { getConfig } from "../../config/index.js";
|
|
7
|
+
import { list as listLogs } from "./event-log.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Express router stack'ini dolaşır. İç API kırılgan; yalnızca gözlem için.
|
|
11
|
+
*
|
|
12
|
+
* @param {import('express').Express} app
|
|
13
|
+
* @returns {{ method: string, path: string }[]}
|
|
14
|
+
*/
|
|
15
|
+
export function listExpressRoutes(app) {
|
|
16
|
+
/** @type {{ method: string, path: string }[]} */
|
|
17
|
+
const out = [];
|
|
18
|
+
const stack = /** @type {any} */ (app)?._router?.stack;
|
|
19
|
+
if (!Array.isArray(stack)) return out;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {any[]} layers
|
|
23
|
+
* @param {string} prefix
|
|
24
|
+
*/
|
|
25
|
+
function walk(layers, prefix) {
|
|
26
|
+
for (const layer of layers) {
|
|
27
|
+
if (layer.route) {
|
|
28
|
+
const routePath = joinPath(prefix, layer.route.path);
|
|
29
|
+
const methods = Object.keys(layer.route.methods || {}).filter(
|
|
30
|
+
(name) => layer.route.methods[name],
|
|
31
|
+
);
|
|
32
|
+
for (const method of methods) {
|
|
33
|
+
out.push({ method: method.toUpperCase(), path: routePath });
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (layer.name === "router" && layer.handle?.stack) {
|
|
39
|
+
const mount = layer.regexp ? regexpToPrefix(layer.regexp) : "";
|
|
40
|
+
walk(layer.handle.stack, joinPath(prefix, mount));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
walk(stack, "");
|
|
46
|
+
return out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} a
|
|
51
|
+
* @param {string} b
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function joinPath(a, b) {
|
|
55
|
+
if (!a) return b || "/";
|
|
56
|
+
if (!b || b === "/") return a || "/";
|
|
57
|
+
return `${a.replace(/\/+$/, "")}/${String(b).replace(/^\/+/, "")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Express mount regexp → okunabilir önek. Tam doğruluk şart değil.
|
|
62
|
+
*
|
|
63
|
+
* @param {RegExp} regexp
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function regexpToPrefix(regexp) {
|
|
67
|
+
const source = regexp.source
|
|
68
|
+
.replace("^\\/", "/")
|
|
69
|
+
.replace("\\/?(?=\\/|$)", "")
|
|
70
|
+
.replace(/\$$/, "")
|
|
71
|
+
.replace(/\\\//g, "/")
|
|
72
|
+
.replace(/\(\?:\(\?=.*$/, "");
|
|
73
|
+
if (!source.startsWith("/")) return "";
|
|
74
|
+
const cleaned = source.replace(/[^a-zA-Z0-9/_\-.:*]/g, "");
|
|
75
|
+
return cleaned || "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `routes/` altındaki (veya config.routes listesindeki) modül dosyaları.
|
|
80
|
+
*
|
|
81
|
+
* @returns {{ file: string, relative: string }[]}
|
|
82
|
+
*/
|
|
83
|
+
export function listRouteModules() {
|
|
84
|
+
const config = getConfig();
|
|
85
|
+
const root = config.root;
|
|
86
|
+
|
|
87
|
+
const files = config.routes
|
|
88
|
+
? config.routes.map((entry) => path.resolve(root, entry))
|
|
89
|
+
: discover(config.dirs.routes);
|
|
90
|
+
|
|
91
|
+
return files.map((file) => ({
|
|
92
|
+
file,
|
|
93
|
+
relative: path.relative(root, file).replaceAll("\\", "/"),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} dir
|
|
99
|
+
* @param {string[]} [out]
|
|
100
|
+
* @returns {string[]}
|
|
101
|
+
*/
|
|
102
|
+
function discover(dir, out = []) {
|
|
103
|
+
if (!fs.existsSync(dir)) return out;
|
|
104
|
+
|
|
105
|
+
const entries = fs
|
|
106
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
107
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
108
|
+
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const full = path.join(dir, entry.name);
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
discover(full, out);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!/\.(js|mjs)$/.test(entry.name)) continue;
|
|
116
|
+
if (entry.name.startsWith("_")) continue;
|
|
117
|
+
out.push(full);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `views/` altındaki şablon ve bileşen dosyaları.
|
|
125
|
+
*
|
|
126
|
+
* @returns {{ relative: string, kind: "ejs" | "js" | "other" }[]}
|
|
127
|
+
*/
|
|
128
|
+
export function listViews() {
|
|
129
|
+
const config = getConfig();
|
|
130
|
+
const root = config.dirs.views;
|
|
131
|
+
/** @type {{ relative: string, kind: "ejs" | "js" | "other" }[]} */
|
|
132
|
+
const out = [];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} dir
|
|
136
|
+
*/
|
|
137
|
+
function walk(dir) {
|
|
138
|
+
if (!fs.existsSync(dir)) return;
|
|
139
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
|
140
|
+
a.name.localeCompare(b.name),
|
|
141
|
+
)) {
|
|
142
|
+
const full = path.join(dir, entry.name);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
walk(full);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const relative = path.relative(root, full).replaceAll("\\", "/");
|
|
148
|
+
const kind = entry.name.endsWith(".ejs")
|
|
149
|
+
? "ejs"
|
|
150
|
+
: /\.(js|mjs)$/.test(entry.name)
|
|
151
|
+
? "js"
|
|
152
|
+
: "other";
|
|
153
|
+
out.push({ relative, kind });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
walk(root);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Son HTTP log'larından path başına özet.
|
|
163
|
+
*
|
|
164
|
+
* @returns {Record<string, { status: number, ms: number, cache: string | null, at: number, count: number }>}
|
|
165
|
+
*/
|
|
166
|
+
export function routeActivity() {
|
|
167
|
+
/** @type {Record<string, { status: number, ms: number, cache: string | null, at: number, count: number }>} */
|
|
168
|
+
const byPath = {};
|
|
169
|
+
|
|
170
|
+
for (const entry of listLogs(0, 500)) {
|
|
171
|
+
if (entry.kind !== "http") continue;
|
|
172
|
+
const key = typeof entry.route === "string" && entry.route
|
|
173
|
+
? entry.route
|
|
174
|
+
: String(entry.path ?? entry.url ?? "");
|
|
175
|
+
if (!key) continue;
|
|
176
|
+
|
|
177
|
+
const prev = byPath[key];
|
|
178
|
+
byPath[key] = {
|
|
179
|
+
status: Number(entry.status) || 0,
|
|
180
|
+
ms: Number(entry.ms) || 0,
|
|
181
|
+
cache: /** @type {string | null} */ (entry.cache ?? null),
|
|
182
|
+
at: entry.at,
|
|
183
|
+
count: (prev?.count ?? 0) + 1,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return byPath;
|
|
188
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework yönetim paneli (`/_jskelet/admin`).
|
|
3
|
+
*
|
|
4
|
+
* Dev overlay'den ayrı: `NODE_ENV`'e bakmaz, yalnızca `admin().enabled` /
|
|
5
|
+
* `JSKELET_ADMIN` ile açılır. Kapalıyken modül yüklenmez ve yol yoktur.
|
|
6
|
+
*
|
|
7
|
+
* Şifre süreç başlangıcında üretilir ve log kutusunda bir kez basılır.
|
|
8
|
+
*/
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import * as log from "../../log.mjs";
|
|
11
|
+
import { getConfig } from "../../config/index.js";
|
|
12
|
+
import { PASSWORD } from "./auth.js";
|
|
13
|
+
import { createAdminRouter } from "./router.js";
|
|
14
|
+
import { configureEventLog, requestLogMiddleware } from "./event-log.js";
|
|
15
|
+
|
|
16
|
+
/** @type {typeof import('../../config/defaults.js').DEFAULT_ADMIN} */
|
|
17
|
+
let settings;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @returns {typeof import('../../config/defaults.js').DEFAULT_ADMIN}
|
|
21
|
+
*/
|
|
22
|
+
function getSettings() {
|
|
23
|
+
return settings;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Paneli uygulamaya bağlar, istek log middleware'ini kurar ve şifreyi loglar.
|
|
28
|
+
*
|
|
29
|
+
* @param {import('express').Express} app
|
|
30
|
+
*/
|
|
31
|
+
export function mountAdmin(app) {
|
|
32
|
+
settings = getConfig().admin;
|
|
33
|
+
|
|
34
|
+
configureEventLog({
|
|
35
|
+
basePath: settings.basePath,
|
|
36
|
+
logSize: settings.logSize,
|
|
37
|
+
prewarmUserAgent: String(getConfig().brand.prewarmUserAgent ?? ""),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
app.use(settings.basePath, createAdminRouter(getSettings, app));
|
|
41
|
+
// Route'lardan önce mount edilir; `finish` anında Express `req.route`
|
|
42
|
+
// doldurmuş olur. Admin yolları ring'e yazılmaz.
|
|
43
|
+
app.use(requestLogMiddleware());
|
|
44
|
+
|
|
45
|
+
const port = process.env.PORT ?? 3000;
|
|
46
|
+
log.box({
|
|
47
|
+
title: "ADMIN",
|
|
48
|
+
lines: [
|
|
49
|
+
`http://localhost:${port}${settings.basePath}`,
|
|
50
|
+
"",
|
|
51
|
+
`password ${PASSWORD}`,
|
|
52
|
+
"",
|
|
53
|
+
"Valid until this process restarts.",
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin paneli Express router'ı.
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import express from "express";
|
|
7
|
+
import { FRAMEWORK_ROOT } from "../../config/index.js";
|
|
8
|
+
import {
|
|
9
|
+
ACTION_HEADER,
|
|
10
|
+
authenticated,
|
|
11
|
+
clearSessionCookie,
|
|
12
|
+
createSession,
|
|
13
|
+
destroySession,
|
|
14
|
+
passwordMatches,
|
|
15
|
+
sessionCookie,
|
|
16
|
+
} from "./auth.js";
|
|
17
|
+
import { clearOffender, gateMiddleware, noteFailure } from "./gate.js";
|
|
18
|
+
import { snapshot } from "./snapshot.js";
|
|
19
|
+
import { runAction } from "./actions.js";
|
|
20
|
+
import {
|
|
21
|
+
list as listLogs,
|
|
22
|
+
subscribeLive,
|
|
23
|
+
} from "./event-log.js";
|
|
24
|
+
import {
|
|
25
|
+
listExpressRoutes,
|
|
26
|
+
listRouteModules,
|
|
27
|
+
listViews,
|
|
28
|
+
routeActivity,
|
|
29
|
+
} from "./inventory.js";
|
|
30
|
+
import {
|
|
31
|
+
cloudflareConfigured,
|
|
32
|
+
fetchCacheAnalytics,
|
|
33
|
+
fetchCloudflareOverview,
|
|
34
|
+
fetchPathEdges,
|
|
35
|
+
} from "../cloudflare.js";
|
|
36
|
+
|
|
37
|
+
const PANEL_DIR = path.join(FRAMEWORK_ROOT, "src", "client", "admin");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {() => typeof import('../../config/defaults.js').DEFAULT_ADMIN} getSettings
|
|
41
|
+
* @param {import('express').Express} app
|
|
42
|
+
* @returns {import('express').Router}
|
|
43
|
+
*/
|
|
44
|
+
export function createAdminRouter(getSettings, app) {
|
|
45
|
+
const api = express.Router();
|
|
46
|
+
|
|
47
|
+
api.use((req, res, next) => {
|
|
48
|
+
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
49
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet");
|
|
50
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
51
|
+
next();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
api.use(gateMiddleware(getSettings));
|
|
55
|
+
|
|
56
|
+
// Soft page routes → aynı shell (client path ile sekme seçer).
|
|
57
|
+
// JSON API'ler ayrı `/api/*` altında: `/logs` hem sayfa hem döküm olamaz.
|
|
58
|
+
const shellPages = ["/", "/cache", "/routes", "/views", "/logs", "/system"];
|
|
59
|
+
for (const page of shellPages) {
|
|
60
|
+
api.get(page, (req, res) => {
|
|
61
|
+
const settings = getSettings();
|
|
62
|
+
const bare = req.originalUrl.split("?")[0];
|
|
63
|
+
if (page === "/" && !bare.endsWith("/")) {
|
|
64
|
+
res.redirect(302, `${settings.basePath}/`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
res.type("html");
|
|
69
|
+
sendFile(res, authenticated(req) ? "panel.html" : "login.html");
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
api.get("/panel.css", (req, res) => {
|
|
74
|
+
res.type("text/css");
|
|
75
|
+
sendFile(res, "panel.css");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
api.get("/i18n.js", (req, res) => {
|
|
79
|
+
res.type("application/javascript");
|
|
80
|
+
sendFile(res, "i18n.js");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
api.get("/logo.png", (req, res) => {
|
|
84
|
+
res.type("image/png");
|
|
85
|
+
fs.createReadStream(path.join(FRAMEWORK_ROOT, "src", "logo.png")).pipe(res);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
api.post("/login", express.json({ limit: "4kb" }), (req, res) => {
|
|
89
|
+
const settings = getSettings();
|
|
90
|
+
const password = typeof req.body?.password === "string" ? req.body.password : "";
|
|
91
|
+
|
|
92
|
+
if (!passwordMatches(password)) {
|
|
93
|
+
noteFailure(req, password ? "wrong password" : "empty password", settings);
|
|
94
|
+
res.status(401).json({ ok: false, message: "Invalid password" });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { token, maxAge } = createSession(settings.sessionHours);
|
|
99
|
+
clearOffender(req);
|
|
100
|
+
res.setHeader("Set-Cookie", sessionCookie(settings.basePath, token, maxAge));
|
|
101
|
+
res.json({ ok: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
api.use((req, res, next) => {
|
|
105
|
+
if (authenticated(req)) return next();
|
|
106
|
+
|
|
107
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
108
|
+
noteFailure(req, `unauthenticated ${req.method} ${req.path}`, getSettings());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
res.status(404).type("text/plain").send("Not Found");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
api.post("/logout", (req, res) => {
|
|
115
|
+
const settings = getSettings();
|
|
116
|
+
destroySession(req);
|
|
117
|
+
res.setHeader("Set-Cookie", clearSessionCookie(settings.basePath));
|
|
118
|
+
res.json({ ok: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
api.get("/panel.js", (req, res) => {
|
|
122
|
+
res.type("application/javascript");
|
|
123
|
+
sendFile(res, "panel.js");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
api.get("/data", async (req, res) => {
|
|
127
|
+
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
128
|
+
res.json(await snapshot(query));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
api.get("/api/routes", (req, res) => {
|
|
132
|
+
res.json({
|
|
133
|
+
routes: listExpressRoutes(app),
|
|
134
|
+
modules: listRouteModules(),
|
|
135
|
+
activity: routeActivity(),
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
api.get("/api/views", (req, res) => {
|
|
140
|
+
res.json({ views: listViews() });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
api.get("/api/logs", (req, res) => {
|
|
144
|
+
const after = Number(req.query.after) || 0;
|
|
145
|
+
const limit = Number(req.query.limit) || 200;
|
|
146
|
+
res.json({ entries: listLogs(after, limit) });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
api.get("/api/logs/stream", (req, res) => {
|
|
150
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
151
|
+
res.setHeader("Cache-Control", "no-store, no-cache");
|
|
152
|
+
res.setHeader("Connection", "keep-alive");
|
|
153
|
+
res.flushHeaders?.();
|
|
154
|
+
|
|
155
|
+
const replay = listLogs(0, 100);
|
|
156
|
+
for (const entry of replay) {
|
|
157
|
+
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const unsubscribe = subscribeLive((entry) => {
|
|
161
|
+
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const heartbeat = setInterval(() => {
|
|
165
|
+
res.write(": ping\n\n");
|
|
166
|
+
}, 15000);
|
|
167
|
+
|
|
168
|
+
req.on("close", () => {
|
|
169
|
+
clearInterval(heartbeat);
|
|
170
|
+
unsubscribe();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
api.get("/cloudflare", async (req, res) => {
|
|
175
|
+
res.json(await fetchCloudflareOverview({ force: req.query.force === "1" }));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
api.post("/cloudflare/analytics", express.json({ limit: "8kb" }), async (req, res) => {
|
|
179
|
+
if (!cloudflareConfigured()) {
|
|
180
|
+
res.json({ ok: false, error: "not configured" });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hours = Number(req.body?.hours) || undefined;
|
|
185
|
+
const pathValue = typeof req.body?.path === "string" ? req.body.path.trim() : "";
|
|
186
|
+
res.json(
|
|
187
|
+
pathValue
|
|
188
|
+
? await fetchPathEdges({ path: pathValue, hours })
|
|
189
|
+
: await fetchCacheAnalytics({ hours }),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
api.post("/action", express.json({ limit: "64kb" }), async (req, res) => {
|
|
194
|
+
if (req.get(ACTION_HEADER) !== "1") {
|
|
195
|
+
res.status(404).type("text/plain").send("Not Found");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
res.json(await runAction(req.body ?? {}, req));
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error("[admin] action failed", error);
|
|
203
|
+
res.status(500).json({ ok: false, message: "Action failed, see server logs" });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return api;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {import('express').Response} res
|
|
212
|
+
* @param {string} name
|
|
213
|
+
*/
|
|
214
|
+
function sendFile(res, name) {
|
|
215
|
+
fs.createReadStream(path.join(PANEL_DIR, name)).pipe(res);
|
|
216
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin paneli cache / host / process dökümü.
|
|
3
|
+
*
|
|
4
|
+
* HTML gövdesi ve veri değerleri dönmez — yalnızca meta.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { getConfig } from "../../config/index.js";
|
|
10
|
+
import {
|
|
11
|
+
FRAMEWORK_HOMEPAGE,
|
|
12
|
+
FRAMEWORK_LICENSE,
|
|
13
|
+
FRAMEWORK_NODE_RANGE,
|
|
14
|
+
FRAMEWORK_VERSION,
|
|
15
|
+
} from "../../version.mjs";
|
|
16
|
+
import {
|
|
17
|
+
getHtmlCacheEntries,
|
|
18
|
+
getHtmlCacheSize,
|
|
19
|
+
} from "../html-cache.js";
|
|
20
|
+
import {
|
|
21
|
+
getDataCacheEntries,
|
|
22
|
+
getDataCacheSize,
|
|
23
|
+
} from "../data-cache.js";
|
|
24
|
+
import { getRedisDetails, getRedisStatus } from "../redis.js";
|
|
25
|
+
import { getUpstreamLimiterStatus } from "../upstream-limiter.js";
|
|
26
|
+
import { getCloudflareStatus } from "../cloudflare.js";
|
|
27
|
+
import { prewarmProgress } from "../prewarm.js";
|
|
28
|
+
|
|
29
|
+
/** Listelerin üst sınırı: veri önbelleğinde on binlerce anahtar olabiliyor. */
|
|
30
|
+
const MAX_LISTED = 500;
|
|
31
|
+
|
|
32
|
+
export const BOOT_ID = `${Date.now()}-${process.pid}`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} query Anahtar filtresi (boş → filtre yok).
|
|
36
|
+
* @returns {Promise<object>}
|
|
37
|
+
*/
|
|
38
|
+
export async function snapshot(query) {
|
|
39
|
+
const term = query.trim().toLowerCase();
|
|
40
|
+
/** @param {{ key: string }} entry */
|
|
41
|
+
const matches = (entry) => !term || entry.key.toLowerCase().includes(term);
|
|
42
|
+
|
|
43
|
+
const html = getHtmlCacheEntries();
|
|
44
|
+
const data = getDataCacheEntries();
|
|
45
|
+
|
|
46
|
+
const htmlMatched = html.filter(matches);
|
|
47
|
+
const dataMatched = data.filter(matches);
|
|
48
|
+
const usage = process.memoryUsage();
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
boot: BOOT_ID,
|
|
52
|
+
generatedAt: Date.now(),
|
|
53
|
+
release: {
|
|
54
|
+
version: FRAMEWORK_VERSION,
|
|
55
|
+
license: FRAMEWORK_LICENSE,
|
|
56
|
+
node: FRAMEWORK_NODE_RANGE,
|
|
57
|
+
homepage: FRAMEWORK_HOMEPAGE,
|
|
58
|
+
},
|
|
59
|
+
host: await hostStatus(),
|
|
60
|
+
process: {
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
node: process.version,
|
|
63
|
+
uptime: process.uptime(),
|
|
64
|
+
env: process.env.NODE_ENV ?? "production",
|
|
65
|
+
memory: { rss: usage.rss, heapUsed: usage.heapUsed },
|
|
66
|
+
},
|
|
67
|
+
html: {
|
|
68
|
+
size: getHtmlCacheSize(),
|
|
69
|
+
maxEntries: getConfig().htmlMaxEntries,
|
|
70
|
+
bytes: html.reduce((total, entry) => total + entry.bytes, 0),
|
|
71
|
+
stale: html.filter((entry) => entry.stale).length,
|
|
72
|
+
matched: htmlMatched.length,
|
|
73
|
+
entries: htmlMatched
|
|
74
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
75
|
+
.slice(0, MAX_LISTED)
|
|
76
|
+
.map((entry) => ({
|
|
77
|
+
...entry,
|
|
78
|
+
url: entry.key.endsWith("?") ? entry.key.slice(0, -1) : entry.key,
|
|
79
|
+
})),
|
|
80
|
+
},
|
|
81
|
+
data: {
|
|
82
|
+
size: getDataCacheSize(),
|
|
83
|
+
maxEntries: Number(getConfig().data?.maxEntries) || 0,
|
|
84
|
+
stale: data.filter((entry) => entry.stale).length,
|
|
85
|
+
matched: dataMatched.length,
|
|
86
|
+
entries: dataMatched
|
|
87
|
+
.sort((a, b) => a.expiresIn - b.expiresIn)
|
|
88
|
+
.slice(0, MAX_LISTED),
|
|
89
|
+
},
|
|
90
|
+
redis: { ...getRedisStatus(), ...getRedisDetails() },
|
|
91
|
+
cloudflare: getCloudflareStatus(),
|
|
92
|
+
upstream: getUpstreamLimiterStatus(),
|
|
93
|
+
prewarm: { ...prewarmProgress },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @returns {Promise<object>}
|
|
99
|
+
*/
|
|
100
|
+
async function hostStatus() {
|
|
101
|
+
const total = os.totalmem();
|
|
102
|
+
const free = os.freemem();
|
|
103
|
+
|
|
104
|
+
/** @type {{ path: string, total: number, free: number } | null} */
|
|
105
|
+
let disk = null;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const root = getConfig().root;
|
|
109
|
+
const stats = await fs.promises.statfs(root);
|
|
110
|
+
disk = {
|
|
111
|
+
path: root,
|
|
112
|
+
total: stats.blocks * stats.bsize,
|
|
113
|
+
free: stats.bavail * stats.bsize,
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
disk = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
platform: `${os.platform()} ${os.arch()}`,
|
|
121
|
+
cpus: os.cpus().length,
|
|
122
|
+
load: os.loadavg()[0],
|
|
123
|
+
memory: { total, free, used: total - free },
|
|
124
|
+
disk,
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/server/cloudflare.js
CHANGED
|
@@ -306,11 +306,20 @@ async function graphql(query, variables) {
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
/**
|
|
309
|
+
* GraphQL analitik penceresinin iki ucu. Yalnızca `datetime_geq` gönderilirse
|
|
310
|
+
* Cloudflare üst sınırı sorguyu işlediği ana koyuyor; Free zone'larda tavan
|
|
311
|
+
* tam 1 gün ve ağ gecikmesi `1d179ms…` gibi bir taşma üretip reddediyor.
|
|
312
|
+
* İki uç aynı `Date.now()`'dan hesaplanınca pencere tam N saat kalır.
|
|
313
|
+
*
|
|
309
314
|
* @param {number} hours
|
|
310
|
-
* @returns {string
|
|
315
|
+
* @returns {{ since: string, until: string }}
|
|
311
316
|
*/
|
|
312
|
-
function
|
|
313
|
-
|
|
317
|
+
function timeWindow(hours) {
|
|
318
|
+
const untilMs = Date.now();
|
|
319
|
+
return {
|
|
320
|
+
since: new Date(untilMs - hours * 3600_000).toISOString(),
|
|
321
|
+
until: new Date(untilMs).toISOString(),
|
|
322
|
+
};
|
|
314
323
|
}
|
|
315
324
|
|
|
316
325
|
/**
|
|
@@ -330,13 +339,14 @@ function since(hours) {
|
|
|
330
339
|
export async function fetchCacheAnalytics(options = {}) {
|
|
331
340
|
const config = settings();
|
|
332
341
|
const hours = Math.max(1, Math.min(72, options.hours ?? config.analyticsHours));
|
|
342
|
+
const { since, until } = timeWindow(hours);
|
|
333
343
|
|
|
334
344
|
const query = `
|
|
335
|
-
query CacheStatus($zone: String!, $since: Time!) {
|
|
345
|
+
query CacheStatus($zone: String!, $since: Time!, $until: Time!) {
|
|
336
346
|
viewer {
|
|
337
347
|
zones(filter: { zoneTag: $zone }) {
|
|
338
348
|
httpRequestsAdaptiveGroups(
|
|
339
|
-
filter: { datetime_geq: $since }
|
|
349
|
+
filter: { datetime_geq: $since, datetime_leq: $until }
|
|
340
350
|
limit: 50
|
|
341
351
|
orderBy: [count_DESC]
|
|
342
352
|
) {
|
|
@@ -348,7 +358,7 @@ export async function fetchCacheAnalytics(options = {}) {
|
|
|
348
358
|
}
|
|
349
359
|
}`;
|
|
350
360
|
|
|
351
|
-
const response = await graphql(query, { zone: config.zoneId, since
|
|
361
|
+
const response = await graphql(query, { zone: config.zoneId, since, until });
|
|
352
362
|
if (!response.ok) return { ok: false, hours, sampled: true, rows: [], error: response.error };
|
|
353
363
|
|
|
354
364
|
const groups = response.data?.viewer?.zones?.[0]?.httpRequestsAdaptiveGroups ?? [];
|
|
@@ -381,13 +391,14 @@ export async function fetchCacheAnalytics(options = {}) {
|
|
|
381
391
|
export async function fetchPathEdges({ path, hours }) {
|
|
382
392
|
const config = settings();
|
|
383
393
|
const window = Math.max(1, Math.min(72, hours ?? config.analyticsHours));
|
|
394
|
+
const { since, until } = timeWindow(window);
|
|
384
395
|
|
|
385
396
|
const query = `
|
|
386
|
-
query PathEdges($zone: String!, $since: Time!, $path: String!) {
|
|
397
|
+
query PathEdges($zone: String!, $since: Time!, $until: Time!, $path: String!) {
|
|
387
398
|
viewer {
|
|
388
399
|
zones(filter: { zoneTag: $zone }) {
|
|
389
400
|
httpRequestsAdaptiveGroups(
|
|
390
|
-
filter: { datetime_geq: $since, clientRequestPath: $path }
|
|
401
|
+
filter: { datetime_geq: $since, datetime_leq: $until, clientRequestPath: $path }
|
|
391
402
|
limit: 500
|
|
392
403
|
orderBy: [count_DESC]
|
|
393
404
|
) {
|
|
@@ -400,7 +411,8 @@ export async function fetchPathEdges({ path, hours }) {
|
|
|
400
411
|
|
|
401
412
|
const response = await graphql(query, {
|
|
402
413
|
zone: config.zoneId,
|
|
403
|
-
since
|
|
414
|
+
since,
|
|
415
|
+
until,
|
|
404
416
|
path,
|
|
405
417
|
});
|
|
406
418
|
|