diff --git a/services/matrix-stats-bot/CLAUDE.md b/services/matrix-stats-bot/CLAUDE.md index 402730a38..062439ec1 100644 --- a/services/matrix-stats-bot/CLAUDE.md +++ b/services/matrix-stats-bot/CLAUDE.md @@ -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://... ``` diff --git a/services/matrix-stats-bot/src/bot/bot.module.ts b/services/matrix-stats-bot/src/bot/bot.module.ts index 50b80cbcb..8b2e59c33 100644 --- a/services/matrix-stats-bot/src/bot/bot.module.ts +++ b/services/matrix-stats-bot/src/bot/bot.module.ts @@ -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', }), diff --git a/services/matrix-stats-bot/src/bot/matrix.service.ts b/services/matrix-stats-bot/src/bot/matrix.service.ts index e40e64077..b1e9a9ec7 100644 --- a/services/matrix-stats-bot/src/bot/matrix.service.ts +++ b/services/matrix-stats-bot/src/bot/matrix.service.ts @@ -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) { diff --git a/services/matrix-stats-bot/src/config/configuration.ts b/services/matrix-stats-bot/src/config/configuration.ts index 90a0aedd6..0be0e33fc 100644 --- a/services/matrix-stats-bot/src/config/configuration.ts +++ b/services/matrix-stats-bot/src/config/configuration.ts @@ -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 diff --git a/services/matrix-stats-bot/src/infrastructure/infrastructure.module.ts b/services/matrix-stats-bot/src/infrastructure/infrastructure.module.ts new file mode 100644 index 000000000..9b4e2ef62 --- /dev/null +++ b/services/matrix-stats-bot/src/infrastructure/infrastructure.module.ts @@ -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 {} diff --git a/services/matrix-stats-bot/src/infrastructure/infrastructure.service.ts b/services/matrix-stats-bot/src/infrastructure/infrastructure.service.ts new file mode 100644 index 000000000..d0f976b92 --- /dev/null +++ b/services/matrix-stats-bot/src/infrastructure/infrastructure.service.ts @@ -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 { + 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 { + 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(); + 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 { + 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(); + const errorMap = new Map(); + const latencyMap = new Map(); + + 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 = { + '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 { + 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 { + 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; + } +} diff --git a/services/matrix-stats-bot/src/prometheus/prometheus.module.ts b/services/matrix-stats-bot/src/prometheus/prometheus.module.ts new file mode 100644 index 000000000..179374101 --- /dev/null +++ b/services/matrix-stats-bot/src/prometheus/prometheus.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PrometheusService } from './prometheus.service'; + +@Module({ + providers: [PrometheusService], + exports: [PrometheusService], +}) +export class PrometheusModule {} diff --git a/services/matrix-stats-bot/src/prometheus/prometheus.service.ts b/services/matrix-stats-bot/src/prometheus/prometheus.service.ts new file mode 100644 index 000000000..0d823054c --- /dev/null +++ b/services/matrix-stats-bot/src/prometheus/prometheus.service.ts @@ -0,0 +1,65 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface PrometheusResult { + metric: Record; + 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('prometheus.url') || 'http://localhost:9090'; + } + + async query(promql: string): Promise { + 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 { + const results = await this.query(promql); + if (results.length === 0) return null; + return parseFloat(results[0].value[1]); + } + + async getValues(promql: string): Promise> { + const results = await this.query(promql); + const values = new Map(); + 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; + } +}