feat: add monitoring dashboard (Prometheus + Grafana + Umami + Admin)

Phase 1: Infrastructure
- Add docker/prometheus/prometheus.yml with scrape configs for all services
- Add docker/grafana/provisioning for auto-configured datasources
- Add docker/grafana/dashboards (system-overview, backends-docker)
- Update docker-compose.macmini.yml with monitoring services:
  - prometheus, grafana, node-exporter, cadvisor
  - postgres-exporter, redis-exporter, umami
- Add grafana.mana.how and analytics.mana.how to Caddyfile

Phase 2: Backend Metrics
- Create packages/shared-nestjs-metrics with:
  - MetricsModule (auto /metrics endpoint)
  - MetricsService (Counter, Histogram, Gauge helpers)
  - MetricsMiddleware (auto HTTP request tracking)

Phase 3: Umami Web Analytics
- Add Umami tracking scripts to all landing pages
- Add Umami tracking scripts to all web apps
- Create scripts/mac-mini/setup-umami-db.sh

Phase 4: Admin Dashboard (ManaCore Web)
- Add admin routes: /admin, /admin/users, /admin/system
- Create StatCard, QuickLinks, UserTable components
- Add Admin link to navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-23 15:31:39 +01:00
parent ad7a84feef
commit 6d86a08d63
36 changed files with 2779 additions and 559 deletions

View file

@ -0,0 +1,39 @@
{
"name": "@manacore/shared-nestjs-metrics",
"version": "1.0.0",
"description": "Prometheus metrics module for NestJS backends",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepublishOnly": "pnpm build",
"lint": "eslint ."
},
"files": [
"dist"
],
"dependencies": {
"prom-client": "^15.1.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
},
"devDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"keywords": [
"nestjs",
"metrics",
"prometheus",
"monitoring",
"manacore"
],
"author": "Mana Core Team",
"license": "MIT"
}

View file

@ -0,0 +1,62 @@
/**
* @manacore/shared-nestjs-metrics
*
* Prometheus metrics module for NestJS backends.
* Automatically tracks HTTP requests, duration, and provides custom metrics.
*
* @example
* ```typescript
* import { MetricsModule } from '@manacore/shared-nestjs-metrics';
*
* @Module({
* imports: [
* MetricsModule.register({
* prefix: 'myapp_',
* defaultLabels: { app: 'my-backend' },
* }),
* ],
* })
* export class AppModule {}
* ```
*
* The module automatically:
* - Exposes a `/metrics` endpoint for Prometheus scraping
* - Tracks HTTP request count, duration, and in-flight requests
* - Collects default Node.js metrics (CPU, memory, event loop)
*
* Custom metrics can be created via the MetricsService:
* ```typescript
* @Injectable()
* export class MyService {
* private readonly aiRequestCounter: Counter;
*
* constructor(private readonly metricsService: MetricsService) {
* this.aiRequestCounter = metricsService.createCounter(
* 'ai_requests_total',
* 'Total AI requests',
* ['model']
* );
* }
*
* async processAI(model: string) {
* this.aiRequestCounter.inc({ model });
* // ... process
* }
* }
* ```
*/
// Module
export { MetricsModule, MetricsModuleOptions } from './metrics.module';
// Service
export { MetricsService, MetricsServiceOptions } from './metrics.service';
// Middleware
export { MetricsMiddleware } from './metrics.middleware';
// Controller
export { MetricsController } from './metrics.controller';
// Re-export prom-client types for convenience
export { Counter, Histogram, Gauge, Summary } from 'prom-client';

View file

@ -0,0 +1,15 @@
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';
import { MetricsService } from './metrics.service';
@Controller()
export class MetricsController {
constructor(private readonly metricsService: MetricsService) {}
@Get('metrics')
async getMetrics(@Res() res: Response): Promise<void> {
const metrics = await this.metricsService.getMetrics();
res.set('Content-Type', this.metricsService.getContentType());
res.send(metrics);
}
}

View file

@ -0,0 +1,35 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { MetricsService } from './metrics.service';
@Injectable()
export class MetricsMiddleware implements NestMiddleware {
constructor(private readonly metricsService: MetricsService) {}
use(req: Request, res: Response, next: NextFunction): void {
// Skip metrics endpoint itself to avoid recursion
if (req.path === '/metrics') {
return next();
}
const startTime = Date.now();
const method = req.method;
// Track in-flight requests
this.metricsService.incrementInFlight(method);
// Hook into response finish
res.on('finish', () => {
const duration = Date.now() - startTime;
const status = res.statusCode;
const path = req.route?.path || req.path;
// Record metrics
this.metricsService.incrementHttpRequests(method, path, status);
this.metricsService.observeHttpDuration(method, path, status, duration);
this.metricsService.decrementInFlight(method);
});
next();
}
}

View file

@ -0,0 +1,79 @@
import { Module, DynamicModule, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { MetricsService, MetricsServiceOptions } from './metrics.service';
import { MetricsMiddleware } from './metrics.middleware';
import { MetricsController } from './metrics.controller';
export interface MetricsModuleOptions extends MetricsServiceOptions {
/**
* Path for metrics endpoint (default: '/metrics')
*/
path?: string;
/**
* Paths to exclude from metrics collection
*/
excludePaths?: string[];
/**
* Whether to register the metrics endpoint controller (default: true)
*/
registerController?: boolean;
}
@Module({})
export class MetricsModule implements NestModule {
private static options: MetricsModuleOptions = {};
/**
* Register the metrics module with options
*
* @example
* ```typescript
* import { MetricsModule } from '@manacore/shared-nestjs-metrics';
*
* @Module({
* imports: [
* MetricsModule.register({
* prefix: 'myapp_',
* defaultLabels: { app: 'my-backend' },
* }),
* ],
* })
* export class AppModule {}
* ```
*/
static register(options: MetricsModuleOptions = {}): DynamicModule {
MetricsModule.options = options;
const providers = [
{
provide: MetricsService,
useFactory: () => new MetricsService(options),
},
MetricsMiddleware,
];
const controllers = options.registerController !== false ? [MetricsController] : [];
return {
module: MetricsModule,
controllers,
providers,
exports: [MetricsService],
global: true,
};
}
configure(consumer: MiddlewareConsumer) {
const excludePaths = MetricsModule.options.excludePaths || [];
const metricsPath = MetricsModule.options.path || '/metrics';
// Always exclude the metrics endpoint itself
const allExcludePaths = [...excludePaths, metricsPath];
consumer
.apply(MetricsMiddleware)
.exclude(...allExcludePaths)
.forRoutes('*');
}
}

View file

@ -0,0 +1,173 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics, register } from 'prom-client';
export interface MetricsServiceOptions {
prefix?: string;
defaultLabels?: Record<string, string>;
collectDefaultMetrics?: boolean;
}
@Injectable()
export class MetricsService implements OnModuleInit {
private readonly registry: Registry;
private readonly httpRequestsTotal: Counter;
private readonly httpRequestDuration: Histogram;
private readonly httpRequestsInFlight: Gauge;
constructor(private readonly options: MetricsServiceOptions = {}) {
this.registry = register;
const prefix = options.prefix || '';
// Set default labels if provided
if (options.defaultLabels) {
this.registry.setDefaultLabels(options.defaultLabels);
}
// HTTP Request Counter
this.httpRequestsTotal = new Counter({
name: `${prefix}http_requests_total`,
help: 'Total number of HTTP requests',
labelNames: ['method', 'path', 'status'],
registers: [this.registry],
});
// HTTP Request Duration Histogram
this.httpRequestDuration = new Histogram({
name: `${prefix}http_request_duration_seconds`,
help: 'HTTP request duration in seconds',
labelNames: ['method', 'path', 'status'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [this.registry],
});
// HTTP Requests In Flight
this.httpRequestsInFlight = new Gauge({
name: `${prefix}http_requests_in_flight`,
help: 'Number of HTTP requests currently being processed',
labelNames: ['method'],
registers: [this.registry],
});
}
onModuleInit() {
// Collect default Node.js metrics (CPU, memory, event loop, etc.)
if (this.options.collectDefaultMetrics !== false) {
collectDefaultMetrics({
register: this.registry,
prefix: this.options.prefix || '',
});
}
}
/**
* Increment HTTP request counter
*/
incrementHttpRequests(method: string, path: string, status: number): void {
this.httpRequestsTotal.inc({
method,
path: this.normalizePath(path),
status: String(status),
});
}
/**
* Observe HTTP request duration
*/
observeHttpDuration(method: string, path: string, status: number, durationMs: number): void {
this.httpRequestDuration.observe(
{
method,
path: this.normalizePath(path),
status: String(status),
},
durationMs / 1000 // Convert to seconds
);
}
/**
* Increment in-flight requests
*/
incrementInFlight(method: string): void {
this.httpRequestsInFlight.inc({ method });
}
/**
* Decrement in-flight requests
*/
decrementInFlight(method: string): void {
this.httpRequestsInFlight.dec({ method });
}
/**
* Get all metrics as Prometheus text format
*/
async getMetrics(): Promise<string> {
return this.registry.metrics();
}
/**
* Get content type for metrics endpoint
*/
getContentType(): string {
return this.registry.contentType;
}
/**
* Create a custom counter
*/
createCounter(name: string, help: string, labelNames: string[] = []): Counter {
return new Counter({
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
help,
labelNames,
registers: [this.registry],
});
}
/**
* Create a custom histogram
*/
createHistogram(
name: string,
help: string,
labelNames: string[] = [],
buckets?: number[]
): Histogram {
return new Histogram({
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
help,
labelNames,
buckets,
registers: [this.registry],
});
}
/**
* Create a custom gauge
*/
createGauge(name: string, help: string, labelNames: string[] = []): Gauge {
return new Gauge({
name: this.options.prefix ? `${this.options.prefix}${name}` : name,
help,
labelNames,
registers: [this.registry],
});
}
/**
* Normalize path to prevent high cardinality
* Replaces UUIDs and numeric IDs with placeholders
*/
private normalizePath(path: string): string {
return (
path
// Replace UUIDs
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
// Replace numeric IDs
.replace(/\/\d+/g, '/:id')
// Remove query strings
.split('?')[0]
);
}
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "CommonJS",
"lib": ["ES2021"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}