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.
@@ -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
+ }
@@ -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} ISO zaman damgası
315
+ * @returns {{ since: string, until: string }}
311
316
  */
312
- function since(hours) {
313
- return new Date(Date.now() - hours * 3600_000).toISOString();
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: since(hours) });
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: since(window),
414
+ since,
415
+ until,
404
416
  path,
405
417
  });
406
418