feat(stats-bot): add infrastructure monitoring commands

Add 5 new commands powered by Prometheus/VictoriaMetrics:
- !system: Mac Mini status (CPU, RAM, Disk, Uptime, Load)
- !services: Backend service health (UP/DOWN)
- !traffic: HTTP traffic & latency per service
- !db: PostgreSQL & Redis status
- !growth: User growth statistics

New modules:
- PrometheusService: Query Prometheus/VictoriaMetrics API
- InfrastructureService: Generate infrastructure reports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-14 11:24:31 +01:00
parent e88597cd20
commit bd7f19718c
8 changed files with 447 additions and 49 deletions

View file

@ -8,7 +8,7 @@ Matrix Stats Bot delivers analytics from Umami (self-hosted) via Matrix. GDPR-co
- **Framework**: NestJS 10
- **Matrix**: matrix-bot-sdk
- **Analytics**: Umami API
- **Analytics**: Umami API + Prometheus/VictoriaMetrics
- **Scheduling**: @nestjs/schedule
## Commands
@ -22,13 +22,30 @@ pnpm type-check # TypeScript check
## Matrix Commands
### Analytics (Umami)
| Command | Description |
|---------|-------------|
| `!stats` | Overview of all apps (30 days) |
| `!today` | Today's statistics |
| `!week` | This week's statistics |
| `!realtime` | Active visitors right now |
| `!users` | Registered user statistics |
### Infrastructure (Prometheus)
| Command | Description |
|---------|-------------|
| `!system` | Mac Mini status (CPU, RAM, Disk, Uptime) |
| `!services` | Backend service health (UP/DOWN) |
| `!traffic` | HTTP traffic & latency per service |
| `!db` | PostgreSQL & Redis status |
| `!growth` | User growth statistics |
### General
| Command | Description |
|---------|-------------|
| `!status` | Account status |
| `!help` | Show available commands |
## Scheduled Reports
@ -54,6 +71,9 @@ UMAMI_API_URL=http://umami:3000
UMAMI_USERNAME=admin
UMAMI_PASSWORD=xxx
# Prometheus / VictoriaMetrics
PROMETHEUS_URL=http://victoriametrics:8428
# Database (for user counts)
DATABASE_URL=postgresql://...
```

View file

@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
import { AnalyticsModule } from '../analytics/analytics.module';
import { UsersModule } from '../users/users.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot-services';
@Module({
imports: [
AnalyticsModule,
UsersModule,
InfrastructureModule,
TranscriptionModule.register({
sttUrl: process.env.STT_URL || 'http://localhost:3020',
}),

View file

@ -9,6 +9,7 @@ import {
} from '@manacore/matrix-bot-common';
import { AnalyticsService } from '../analytics/analytics.service';
import { UsersService } from '../users/users.service';
import { InfrastructureService } from '../infrastructure/infrastructure.service';
import { TranscriptionService, SessionService, CreditService } from '@manacore/bot-services';
@Injectable()
@ -22,12 +23,18 @@ export class MatrixService extends BaseMatrixService {
{ keywords: ['woche', 'week', 'wochenstatistik'], command: 'week' },
{ keywords: ['realtime', 'live', 'aktive', 'jetzt'], command: 'realtime' },
{ keywords: ['users', 'benutzer', 'nutzer', 'registrierte'], command: 'users' },
{ keywords: ['system', 'server', 'macmini', 'mac'], command: 'system' },
{ keywords: ['services', 'dienste', 'backends', 'health'], command: 'services' },
{ keywords: ['traffic', 'requests', 'http', 'api'], command: 'traffic' },
{ keywords: ['db', 'database', 'datenbank', 'postgres', 'redis'], command: 'db' },
{ keywords: ['growth', 'wachstum', 'registrierungen'], command: 'growth' },
]);
constructor(
configService: ConfigService,
private analyticsService: AnalyticsService,
private usersService: UsersService,
private infrastructureService: InfrastructureService,
private readonly transcriptionService: TranscriptionService,
private sessionService: SessionService,
private creditService: CreditService
@ -95,14 +102,6 @@ export class MatrixService extends BaseMatrixService {
await this.sendHelp(roomId);
break;
case 'login':
await this.handleLogin(roomId, sender, args);
break;
case 'logout':
await this.handleLogout(roomId, sender);
break;
case 'status':
await this.handleStatus(roomId, sender);
break;
@ -127,28 +126,50 @@ export class MatrixService extends BaseMatrixService {
await this.sendUsers(roomId);
break;
case 'system':
await this.sendSystem(roomId);
break;
case 'services':
await this.sendServices(roomId);
break;
case 'traffic':
await this.sendTraffic(roomId);
break;
case 'db':
await this.sendDatabase(roomId);
break;
case 'growth':
await this.sendGrowth(roomId);
break;
default:
await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help`);
}
}
private async sendHelp(roomId: string) {
const helpText = `**📊 ManaCore Stats Bot (DSGVO-konform)**
const helpText = `**📊 ManaCore Stats Bot**
**Account:**
- \`!login email passwort\` - Anmelden
- \`!logout\` - Abmelden
- \`!status\` - Account Status
**Statistiken:**
**Analytics (Umami):**
- \`!stats\` - Übersicht aller Apps (30 Tage)
- \`!today\` - Heutige Statistiken
- \`!week\` - Wochenstatistiken
- \`!realtime\` - Aktive Besucher jetzt
- \`!users\` - Registrierte Benutzer
- \`!help\` - Diese Hilfe
Daten von Umami Analytics (self-hosted).`;
**Infrastruktur (Prometheus):**
- \`!system\` - Mac Mini Status (CPU, RAM, Disk)
- \`!services\` - Backend Service Status
- \`!traffic\` - HTTP Traffic & Latenz
- \`!db\` - Datenbank Status
- \`!growth\` - User Wachstum
**Account:**
- \`!status\` - Account Status
- \`!help\` - Diese Hilfe`;
await this.sendMessage(roomId, helpText);
}
@ -160,7 +181,10 @@ Daten von Umami Analytics (self-hosted).`;
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate stats overview:', error);
await this.sendMessage(roomId, `❌ Fehler beim Laden der Statistiken: ${error instanceof Error ? error.message : String(error)}`);
await this.sendMessage(
roomId,
`❌ Fehler beim Laden der Statistiken: ${error instanceof Error ? error.message : String(error)}`
);
}
}
@ -171,7 +195,10 @@ Daten von Umami Analytics (self-hosted).`;
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate daily report:', error);
await this.sendMessage(roomId, `❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}`);
await this.sendMessage(
roomId,
`❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}`
);
}
}
@ -182,7 +209,10 @@ Daten von Umami Analytics (self-hosted).`;
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate weekly report:', error);
await this.sendMessage(roomId, `❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}`);
await this.sendMessage(
roomId,
`❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}`
);
}
}
@ -192,7 +222,10 @@ Daten von Umami Analytics (self-hosted).`;
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate realtime report:', error);
await this.sendMessage(roomId, `❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}`);
await this.sendMessage(
roomId,
`❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}`
);
}
}
@ -216,34 +249,69 @@ Daten von Umami Analytics (self-hosted).`;
await this.sendMessage(roomId, report);
}
private async handleLogin(roomId: string, sender: string, args: string) {
const parts = args.split(' ');
if (parts.length < 2 || !parts[0] || !parts[1]) {
await this.sendMessage(roomId, 'Verwendung: `!login email passwort`');
return;
}
const [email, password] = parts;
const result = await this.sessionService.login(sender, email, password);
if (result.success) {
const token = await this.sessionService.getToken(sender);
if (token) {
const balance = await this.creditService.getBalance(token);
await this.sendMessage(
roomId,
`✅ Erfolgreich angemeldet als **${email}**\n⚡ Credits: ${balance.balance.toFixed(2)}`
);
} else {
await this.sendMessage(roomId, `✅ Erfolgreich angemeldet als **${email}**`);
}
} else {
await this.sendMessage(roomId, `❌ Anmeldung fehlgeschlagen: ${result.error}`);
private async sendSystem(roomId: string) {
try {
const report = await this.infrastructureService.generateSystemReport();
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate system report:', error);
await this.sendMessage(
roomId,
`❌ Fehler: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async handleLogout(roomId: string, sender: string) {
await this.sessionService.logout(sender);
await this.sendMessage(roomId, '👋 Erfolgreich abgemeldet.');
private async sendServices(roomId: string) {
try {
const report = await this.infrastructureService.generateServicesReport();
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate services report:', error);
await this.sendMessage(
roomId,
`❌ Fehler: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async sendTraffic(roomId: string) {
try {
const report = await this.infrastructureService.generateTrafficReport();
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate traffic report:', error);
await this.sendMessage(
roomId,
`❌ Fehler: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async sendDatabase(roomId: string) {
try {
const report = await this.infrastructureService.generateDatabaseReport();
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate database report:', error);
await this.sendMessage(
roomId,
`❌ Fehler: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async sendGrowth(roomId: string) {
try {
const report = await this.infrastructureService.generateGrowthReport();
await this.sendMessage(roomId, report);
} catch (error) {
this.logger.error('Failed to generate growth report:', error);
await this.sendMessage(
roomId,
`❌ Fehler: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async handleStatus(roomId: string, sender: string) {

View file

@ -15,6 +15,9 @@ export default () => ({
database: {
url: process.env.DATABASE_URL || '',
},
prometheus: {
url: process.env.PROMETHEUS_URL || 'http://localhost:9090',
},
});
// Website IDs from Umami - update these with actual UUIDs

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrometheusModule } from '../prometheus/prometheus.module';
import { InfrastructureService } from './infrastructure.service';
@Module({
imports: [PrometheusModule],
providers: [InfrastructureService],
exports: [InfrastructureService],
})
export class InfrastructureModule {}

View file

@ -0,0 +1,222 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrometheusService } from '../prometheus/prometheus.service';
@Injectable()
export class InfrastructureService {
private readonly logger = new Logger(InfrastructureService.name);
constructor(private readonly prometheus: PrometheusService) {}
async generateSystemReport(): Promise<string> {
const [cpu, memory, disk, uptime, load] = await Promise.all([
this.prometheus.getValue('100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'),
this.prometheus.getValue(
'100 * (1 - ((node_memory_free_bytes + node_memory_cached_bytes + node_memory_buffers_bytes) / node_memory_total_bytes))'
),
this.prometheus.getValue(
'100 - ((node_filesystem_avail_bytes{mountpoint="/",fstype!="rootfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="rootfs"}) * 100)'
),
this.prometheus.getValue('time() - node_boot_time_seconds'),
this.prometheus.getValue('node_load1'),
]);
if (cpu === null && memory === null) {
return '❌ Keine System-Metriken verfügbar. Node Exporter nicht erreichbar.';
}
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
};
const getStatusIcon = (value: number, warn: number, crit: number): string => {
if (value >= crit) return '🔴';
if (value >= warn) return '🟡';
return '🟢';
};
let report = '**🖥️ Mac Mini System Status**\n\n';
report += `${getStatusIcon(cpu || 0, 70, 85)} **CPU:** ${cpu?.toFixed(1) || '?'}%\n`;
report += `${getStatusIcon(memory || 0, 70, 85)} **Memory:** ${memory?.toFixed(1) || '?'}%\n`;
report += `${getStatusIcon(disk || 0, 70, 85)} **Disk:** ${disk?.toFixed(1) || '?'}%\n`;
report += `⏱️ **Uptime:** ${uptime ? formatUptime(uptime) : '?'}\n`;
report += `📊 **Load (1m):** ${load?.toFixed(2) || '?'}`;
return report;
}
async generateServicesReport(): Promise<string> {
const services = [
{ job: 'mana-core-auth', name: 'Auth' },
{ job: 'chat-backend', name: 'Chat' },
{ job: 'todo-backend', name: 'Todo' },
{ job: 'calendar-backend', name: 'Calendar' },
{ job: 'clock-backend', name: 'Clock' },
{ job: 'contacts-backend', name: 'Contacts' },
{ job: 'zitare-backend', name: 'Zitare' },
{ job: 'picture-backend', name: 'Picture' },
];
const results = await this.prometheus.query('up');
const statusMap = new Map<string, number>();
for (const result of results) {
statusMap.set(result.metric.job, parseFloat(result.value[1]));
}
// Also check infrastructure
const pgUp = await this.prometheus.getValue('pg_up');
const redisUp = await this.prometheus.getValue('redis_up');
let report = '**🔧 Service Status**\n\n';
let allUp = true;
for (const service of services) {
const status = statusMap.get(service.job);
if (status === 1) {
report += `🟢 ${service.name}\n`;
} else if (status === 0) {
report += `🔴 ${service.name}\n`;
allUp = false;
} else {
report += `${service.name} (nicht konfiguriert)\n`;
}
}
report += '\n**Infrastruktur:**\n';
report += pgUp === 1 ? '🟢 PostgreSQL\n' : '🔴 PostgreSQL\n';
report += redisUp === 1 ? '🟢 Redis' : '🔴 Redis';
if (allUp && pgUp === 1 && redisUp === 1) {
report =
'**🔧 Service Status**\n\n✅ Alle Services online!\n\n' +
report.split('\n\n').slice(1).join('\n\n');
}
return report;
}
async generateTrafficReport(): Promise<string> {
const [requestRates, errorRates, p95Latency] = await Promise.all([
this.prometheus.query('sum(rate(http_requests_total[5m])) by (job)'),
this.prometheus.query('sum(rate(http_requests_total{status=~"5.."}[5m])) by (job)'),
this.prometheus.query(
'histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))'
),
]);
if (requestRates.length === 0) {
return '❌ Keine Traffic-Metriken verfügbar.';
}
const rateMap = new Map<string, number>();
const errorMap = new Map<string, number>();
const latencyMap = new Map<string, number>();
for (const r of requestRates) {
rateMap.set(r.metric.job, parseFloat(r.value[1]));
}
for (const r of errorRates) {
errorMap.set(r.metric.job, parseFloat(r.value[1]));
}
for (const r of p95Latency) {
latencyMap.set(r.metric.job, parseFloat(r.value[1]));
}
const totalRate = Array.from(rateMap.values()).reduce((a, b) => a + b, 0);
const totalErrors = Array.from(errorMap.values()).reduce((a, b) => a + b, 0);
let report = '**📈 HTTP Traffic**\n\n';
report += `**Gesamt:** ${totalRate.toFixed(2)} req/s\n`;
report += `**5xx Errors:** ${totalErrors.toFixed(3)} req/s\n\n`;
const serviceNames: Record<string, string> = {
'mana-core-auth': 'Auth',
'chat-backend': 'Chat',
'todo-backend': 'Todo',
'calendar-backend': 'Calendar',
'clock-backend': 'Clock',
'contacts-backend': 'Contacts',
};
for (const [job, rate] of rateMap.entries()) {
if (rate < 0.001) continue;
const name = serviceNames[job] || job;
const latency = latencyMap.get(job);
const latencyStr = latency ? `${(latency * 1000).toFixed(0)}ms` : '?';
report += `**${name}:** ${rate.toFixed(2)} req/s (p95: ${latencyStr})\n`;
}
return report;
}
async generateDatabaseReport(): Promise<string> {
const [pgConnections, dbSizes, redisMemory, redisClients] = await Promise.all([
this.prometheus.getValue('sum(pg_stat_activity_count)'),
this.prometheus.query('pg_database_size_bytes{datname!~"template.*|postgres"}'),
this.prometheus.getValue('redis_memory_used_bytes'),
this.prometheus.getValue('redis_connected_clients'),
]);
if (pgConnections === null && redisMemory === null) {
return '❌ Keine Datenbank-Metriken verfügbar.';
}
const formatBytes = (bytes: number): string => {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
let report = '**🗄️ Datenbank Status**\n\n';
report += '**PostgreSQL:**\n';
report += `- Connections: ${pgConnections || '?'}\n`;
if (dbSizes.length > 0) {
const sortedDbs = dbSizes
.map((r) => ({ name: r.metric.datname, size: parseFloat(r.value[1]) }))
.sort((a, b) => b.size - a.size)
.slice(0, 5);
for (const db of sortedDbs) {
report += `- ${db.name}: ${formatBytes(db.size)}\n`;
}
}
report += '\n**Redis:**\n';
report += `- Memory: ${redisMemory ? formatBytes(redisMemory) : '?'}\n`;
report += `- Clients: ${redisClients || '?'}`;
return report;
}
async generateGrowthReport(): Promise<string> {
const [total, verified, today, week, month] = await Promise.all([
this.prometheus.getValue('auth_users_total'),
this.prometheus.getValue('auth_users_verified'),
this.prometheus.getValue('auth_users_created_today'),
this.prometheus.getValue('auth_users_created_this_week'),
this.prometheus.getValue('auth_users_created_this_month'),
]);
if (total === null) {
return '❌ Keine User-Metriken verfügbar. Auth Service nicht erreichbar.';
}
const verificationRate = total && verified ? ((verified / total) * 100).toFixed(1) : '?';
let report = '**📈 User Growth**\n\n';
report += `**Gesamt:** ${total?.toLocaleString() || '?'} User\n`;
report += `**Verifiziert:** ${verified?.toLocaleString() || '?'} (${verificationRate}%)\n\n`;
report += '**Neue Registrierungen:**\n';
report += `- Heute: ${today || 0}\n`;
report += `- Diese Woche: ${week || 0}\n`;
report += `- Dieser Monat: ${month || 0}`;
return report;
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PrometheusService } from './prometheus.service';
@Module({
providers: [PrometheusService],
exports: [PrometheusService],
})
export class PrometheusModule {}

View file

@ -0,0 +1,65 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
interface PrometheusResult {
metric: Record<string, string>;
value: [number, string];
}
interface PrometheusResponse {
status: string;
data: {
resultType: string;
result: PrometheusResult[];
};
}
@Injectable()
export class PrometheusService {
private readonly logger = new Logger(PrometheusService.name);
private readonly baseUrl: string;
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>('prometheus.url') || 'http://localhost:9090';
}
async query(promql: string): Promise<PrometheusResult[]> {
try {
const url = `${this.baseUrl}/api/v1/query?query=${encodeURIComponent(promql)}`;
const response = await fetch(url);
if (!response.ok) {
this.logger.error(`Prometheus query failed: ${response.status}`);
return [];
}
const data = (await response.json()) as PrometheusResponse;
if (data.status !== 'success') {
this.logger.error(`Prometheus query error: ${data.status}`);
return [];
}
return data.data.result;
} catch (error) {
this.logger.error(`Prometheus query error: ${error}`);
return [];
}
}
async getValue(promql: string): Promise<number | null> {
const results = await this.query(promql);
if (results.length === 0) return null;
return parseFloat(results[0].value[1]);
}
async getValues(promql: string): Promise<Map<string, number>> {
const results = await this.query(promql);
const values = new Map<string, number>();
for (const result of results) {
const label =
result.metric.job || result.metric.datname || result.metric.instance || 'unknown';
values.set(label, parseFloat(result.value[1]));
}
return values;
}
}