Files
wekan/models/eventLog.js
T
Lauri Ojansivu 1c636f09b3 CPU-usage monitor + governor + Admin Panel / Problems / CPU usage report
WeKan and FerretDB on one machine can drive CPU high enough to starve other
software. Add a monitor, a governor, and a report. Design: docs/Features/CPU/CPU.md.

Monitor (server/lib/cpuMonitor.js): samples system-wide CPU% from os.cpus()
idle/total deltas on an interval (so it includes WeKan, FerretDB and every other
process). A pure, hysteresis state machine (models/lib/cpuHighTracker.js) turns
the samples into ONLY the START and END of each sustained high-CPU period — two
rows per episode, never a flood. Enters high after 3 consecutive samples >= 85%,
leaves after 3 consecutive < 70%. Each row carries duration, peak, system load,
and what WeKan was doing (setActivity label). Recorded to a new 'cpu' event
stream (server/lib/cpuLog.js).

Governor: cpuMonitor exports isHighCpu() and pauseIfBusy(), so long batch
operations await a short pause and yield the CPU only while the system is busy
(no cost when idle). Wired into the existing-file extension corrector. Tunable
via WEKAN_CPU_* env vars.

Report: 'cpu' added to EVENT_STREAMS; reuses the generic eventStreamReport
(stream="cpu") with search + pagination; a "CPU usage" item is added to the
Admin Panel / Problems side menu (cpuReportTitle i18n).

Tests: cpuHighTracker unit (start/end, duration/peak, no-flap hysteresis;
positive + negative) and cpuMonitorWiring source guards. CHANGELOG + design doc.
2026-07-20 03:11:07 +03:00

148 lines
6.2 KiB
JavaScript

import { Mongo } from 'meteor/mongo';
import { Meteor } from 'meteor/meteor';
import { check, Match } from 'meteor/check';
const { SimpleSchema } = require('/imports/simpleSchema');
// ============================================================================
// EventLog — the single collection backing the Admin Panel → Reports →
// Security / Speed / Tests screens (design: docs/Security/Remediation/WeKan.md).
//
// Stored in the EXISTING WeKan database (MongoDB or FerretDB) via normal Meteor
// JavaScript queries — no new files or databases. FerretDB does NOT write here
// itself; when it detects/remediates a problem it reports it to WeKan, and WeKan
// records it with the same JS query, so the feature works identically on FerretDB,
// MongoDB, etc.
//
// One document per event; the `stream` field ('security' | 'speed' | 'tests')
// discriminates the three Reports.
// ============================================================================
const EventLog = new Mongo.Collection('eventlog');
EventLog.attachSchema(
new SimpleSchema({
stream: { type: String }, // 'security' | 'speed' | 'tests'
at: { type: Date }, // server time of the event
severity: { type: String, optional: true }, // info|low|medium|high|critical
category: { type: String, optional: true }, // general class
bleed: { type: String, optional: true }, // hall-of-fame *Bleed name (or generic)
action: { type: String, optional: true }, // blocked|remediated|sanitized|rate-limited|detected|failed
source: { type: String, optional: true }, // guard/module/test name (wekan… or sqlite…/ferretdb…)
cwe: { type: String, optional: true },
userId: { type: String, optional: true },
detail: { type: String, optional: true },
}),
);
// Per-stream acknowledgment: when an admin clicks "Acknowledge" for a problem
// area, we upsert { stream, at:now } here. The new-problem count for a stream is
// the number of eventlog docs newer than its ack `at` — so acknowledging zeroes
// the count and removes the info from the top of the Admin Panel.
export const EventLogAcks = new Mongo.Collection('eventlogAcks');
EventLogAcks.attachSchema(
new SimpleSchema({
stream: { type: String },
at: { type: Date },
}),
);
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu'];
if (Meteor.isServer) {
// The Security/Speed/Tests report pages filter by `stream` and sort by `at`
// descending (streamSelector + eventLogPage). This compound index makes that a
// bounded index scan instead of a full-collection scan + in-memory sort as the
// event log grows, keeping the paginated tables fast.
const { ensureIndex } = require('/server/lib/mongoStartup');
Meteor.startup(async () => {
await ensureIndex(EventLog, { stream: 1, at: -1 });
await ensureIndex(EventLogAcks, { stream: 1 });
});
async function requireAdmin(context) {
const uid = context.userId;
const user = uid && (await Meteor.users.findOneAsync(uid));
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin only');
}
return user;
}
// Build a read-only find selector for one stream, with an optional
// case-insensitive search across the text columns.
function streamSelector(stream, search) {
const selector = { stream };
if (search) {
const rx = { $regex: String(search).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' };
selector.$or = [{ category: rx }, { bleed: rx }, { source: rx }, { detail: rx }];
}
return selector;
}
Meteor.methods({
// Admin-only: for each stream, the count of events NEWER than its
// acknowledgment. Returns only streams with count > 0, so the Admin Panel
// "Problems" button (red) and the Summary page show exactly what needs
// attention.
async eventLogProblemAreas() {
await requireAdmin(this);
const areas = [];
for (const stream of EVENT_STREAMS) {
const ack = await EventLogAcks.findOneAsync({ stream });
const selector = { stream };
if (ack && ack.at) selector.at = { $gt: ack.at };
const count = await EventLog.find(selector).countAsync();
if (count > 0) areas.push({ stream, count });
}
return areas;
},
// Admin-only: mark the newest problems in the given stream(s) as seen
// (resets each one's count). Accepts a single stream or an array, so the
// Admin Panel banner can acknowledge all checked areas with one button.
async acknowledgeEventLog(streams) {
check(streams, Match.OneOf(String, [String]));
await requireAdmin(this);
const list = Array.isArray(streams) ? streams : [streams];
const now = new Date();
for (const stream of list) {
if (!EVENT_STREAMS.includes(stream)) {
throw new Meteor.Error('invalid-stream', 'Unknown event stream');
}
await EventLogAcks.upsertAsync({ stream }, { $set: { stream, at: now } });
}
return true;
},
// Admin-only, READ-ONLY: total count of events in a stream (optional search),
// for the Security/Speed/Tests detail pages' pagination.
async eventLogCount(stream, search) {
// check() every argument BEFORE any await/throw: Meteor's
// audit-argument-checks otherwise reports "Did not check() all arguments"
// (masking the real error) when requireAdmin throws for a non-admin call.
check(stream, String);
check(search, Match.Optional(String));
await requireAdmin(this);
return EventLog.find(streamSelector(stream, search)).countAsync();
},
// Admin-only, READ-ONLY: one page of a stream's events, newest first.
async eventLogPage(stream, limit, skip, search) {
// check() every argument BEFORE requireAdmin (see eventLogCount).
check(stream, String);
check(limit, Number);
check(skip, Number);
check(search, Match.Optional(String));
await requireAdmin(this);
return EventLog.find(streamSelector(stream, search), {
sort: { at: -1 },
limit: Math.max(1, Math.min(200, limit)),
skip: Math.max(0, skip),
}).fetchAsync();
},
});
}
export default EventLog;