Initial commit: Upload Checkpoint project
This commit is contained in:
commit
c0e3781244
32 changed files with 6121 additions and 0 deletions
132
plugins/stats.js
Normal file
132
plugins/stats.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { registerPlugin, rootDir, loadConfig } from '../index.js';
|
||||
import { Level } from 'level';
|
||||
import ttl from 'level-ttl';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Readable } from 'stream';
|
||||
import cookie from 'cookie';
|
||||
import { getRealIP } from '../utils/network.js';
|
||||
import { parseDuration } from '../utils/time.js';
|
||||
|
||||
// Load stats configuration
|
||||
const statsConfig = {};
|
||||
await loadConfig('stats', statsConfig);
|
||||
|
||||
// Map configuration to internal structure
|
||||
const enabled = statsConfig.Core.Enabled;
|
||||
const statsTTL = parseDuration(statsConfig.Storage.StatsTTL);
|
||||
const statsUIPath = statsConfig.WebUI.StatsUIPath;
|
||||
const statsAPIPath = statsConfig.WebUI.StatsAPIPath;
|
||||
|
||||
// Determine __dirname for ES modules
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Adds createReadStream support to LevelDB instances using async iterator.
|
||||
*/
|
||||
function addReadStreamSupport(dbInstance) {
|
||||
if (!dbInstance.createReadStream) {
|
||||
dbInstance.createReadStream = (opts) =>
|
||||
Readable.from(
|
||||
(async function* () {
|
||||
for await (const [key, value] of dbInstance.iterator(opts)) {
|
||||
yield { key, value };
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
// Initialize LevelDB for stats under db/stats with TTL and stream support
|
||||
const statsDBPath = path.join(rootDir, 'db', 'stats');
|
||||
await fs.mkdir(statsDBPath, { recursive: true });
|
||||
let rawStatsDB = new Level(statsDBPath, { valueEncoding: 'json' });
|
||||
rawStatsDB = addReadStreamSupport(rawStatsDB);
|
||||
const statsDB = ttl(rawStatsDB, { defaultTTL: statsTTL });
|
||||
addReadStreamSupport(statsDB);
|
||||
|
||||
/**
|
||||
* Record a stat event with a metric name and optional data.
|
||||
* @param {string} metric
|
||||
* @param {object} data
|
||||
*/
|
||||
function recordEvent(metric, data = {}) {
|
||||
// Skip if statsDB is not initialized
|
||||
if (typeof statsDB === 'undefined' || !statsDB || typeof statsDB.put !== 'function') {
|
||||
console.warn(`stats: cannot record "${metric}", statsDB not available`);
|
||||
return;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
// key includes metric and timestamp and a random suffix to avoid collisions
|
||||
const key = `${metric}:${timestamp}:${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
// Use callback form to avoid promise chaining
|
||||
statsDB.put(key, { timestamp, metric, ...data }, (err) => {
|
||||
if (err) console.error('stats: failed to record event', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('stats: failed to record event', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for serving the stats HTML UI
|
||||
async function handleStatsPage(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== statsUIPath) return undefined;
|
||||
try {
|
||||
const html = await fs.readFile(path.join(__dirname, 'stats.html'), 'utf8');
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
} catch (e) {
|
||||
return new Response('Stats UI not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for stats API
|
||||
async function handleStatsAPI(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== statsAPIPath) return undefined;
|
||||
const metric = url.searchParams.get('metric');
|
||||
const start = parseInt(url.searchParams.get('start') || '0', 10);
|
||||
const end = parseInt(url.searchParams.get('end') || `${Date.now()}`, 10);
|
||||
const result = [];
|
||||
// Iterate over keys for this metric in the time range
|
||||
for await (const [key, value] of statsDB.iterator({
|
||||
gte: `${metric}:${start}`,
|
||||
lte: `${metric}:${end}\uffff`,
|
||||
})) {
|
||||
result.push(value);
|
||||
}
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Middleware for stats plugin
|
||||
function StatsMiddleware() {
|
||||
return async (request) => {
|
||||
// Always serve stats UI and API first, bypassing auth
|
||||
const pageResp = await handleStatsPage(request);
|
||||
if (pageResp) return pageResp;
|
||||
const apiResp = await handleStatsAPI(request);
|
||||
if (apiResp) return apiResp;
|
||||
|
||||
// For any other routes, do not handle
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// Register the stats plugin
|
||||
if (enabled) {
|
||||
registerPlugin('stats', StatsMiddleware());
|
||||
} else {
|
||||
console.log('Stats plugin disabled via config');
|
||||
}
|
||||
|
||||
// Export recordEvent for other plugins to use
|
||||
export { recordEvent };
|
||||
Loading…
Add table
Add a link
Reference in a new issue