diff --git a/backend/src/drivers/SocketIODriver.ts b/backend/src/drivers/SocketIODriver.ts index 58f1da45..d2e13831 100644 --- a/backend/src/drivers/SocketIODriver.ts +++ b/backend/src/drivers/SocketIODriver.ts @@ -2,6 +2,8 @@ import { Server as HTTPServer } from "http"; import { Server, Socket } from "socket.io"; import { UtilityService } from "../services/UtilityService.js"; import { TokenProcessor } from "../processors/TokenProcessor.js"; +import { AppDataSource } from "../datasources/PostgresDS.js"; +import { EUserType } from "../types/EUserType.js"; export class SocketIODriver { @@ -120,6 +122,23 @@ export class SocketIODriver { }); } + // Allow admin users to join the admin-dashboard room for real-time stats + socket.on('join-admin-room', async () => { + if (!userId) return; + try { + const user = await AppDataSource.manager.findOne( + (await import('../models/DRAUsersPlatform.js')).DRAUsersPlatform, + { where: { id: userId } } + ); + if (user && user.user_type === EUserType.ADMIN) { + socket.join('admin-dashboard'); + console.log(`[Socket.IO] Admin user ${userId} joined admin-dashboard room`); + } + } catch (err) { + console.error('[Socket.IO] Error joining admin room:', err); + } + }); + // Handle disconnection socket.on("disconnect", () => { const userId = (socket as any).userId; @@ -184,5 +203,12 @@ export class SocketIODriver { }); } - + /** + * Emit an event to all sockets in a named room. + */ + public emitToRoom(room: string, event: string, data: any): void { + if (!this.io) return; + this.io.to(room).emit(event, data); + } + } \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 455cd1f0..c351f2d6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -44,6 +44,7 @@ import user_subscriptions from './routes/admin/user_subscriptions.js'; import platform_settings from './routes/admin/platform-settings.js'; import account_cancellations from './routes/admin/account-cancellations.js'; import admin_project_members from './routes/admin/project_members.js'; +import admin_stats from './routes/admin/stats.js'; import public_article from './routes/article.js'; import sitemap from './routes/sitemap.js'; import subscription from './routes/subscription.js'; @@ -243,6 +244,7 @@ app.use('/admin/subscription-tiers', admin_subscription_tiers); app.use('/admin/platform-settings', platform_settings); app.use('/admin/account-cancellations', account_cancellations); app.use('/admin/projects', admin_project_members); +app.use('/admin/stats', admin_stats); app.use('/article', public_article); app.use('/sitemap.txt', sitemap); app.use('/subscription', subscription); diff --git a/backend/src/processors/AdminStatsProcessor.ts b/backend/src/processors/AdminStatsProcessor.ts new file mode 100644 index 00000000..af4f675f --- /dev/null +++ b/backend/src/processors/AdminStatsProcessor.ts @@ -0,0 +1,346 @@ +import { AppDataSource } from '../datasources/PostgresDS.js'; +import { getRedisClient } from '../config/redis.config.js'; +import { ScheduledBackupProcessor } from './ScheduledBackupProcessor.js'; +import { ScheduledBackupService } from '../services/ScheduledBackupService.js'; + +export interface IAdminOverviewStats { + users: { + total: number; + verified: number; + unverified: number; + admins: number; + }; + platform: { + projects: number; + dataSources: number; + dashboards: number; + dataModels: number; + }; + ai: { + totalConversations: number; + totalMessages: number; + activeRedisSessions: number; + }; + content: { + articles: number; + publishedArticles: number; + draftArticles: number; + categories: number; + sitemapUrls: number; + }; + syncHealth: { + totalSources: number; + failedSources: number; + neverSynced: number; + }; +} + +export interface IDataSourceSyncRow { + id: number; + name: string; + data_type: string; + owner_email: string; + last_sync: string | null; + created_at: string | null; + status: 'synced' | 'failed' | 'never'; +} + +export interface ISystemHealthStatus { + database: boolean; + redis: boolean; + backupScheduler: { + enabled: boolean; + isRunning: boolean; + nextRun: Date | null; + lastRun: Date | null; + }; + backupStats: { + totalRuns: number; + successfulRuns: number; + failedRuns: number; + totalSizeBytes: number; + } | null; +} + +export interface ITimeSeriesPoint { + date: string; + count: number; +} + +export class AdminStatsProcessor { + private static instance: AdminStatsProcessor; + + private constructor() {} + + public static getInstance(): AdminStatsProcessor { + if (!AdminStatsProcessor.instance) { + AdminStatsProcessor.instance = new AdminStatsProcessor(); + } + return AdminStatsProcessor.instance; + } + + async getOverviewStats(): Promise { + const manager = AppDataSource.manager; + + const [ + userStats, + platformStats, + aiStats, + contentStats, + syncHealthStats, + ] = await Promise.all([ + this.queryUserStats(manager), + this.queryPlatformStats(manager), + this.queryAIStats(manager), + this.queryContentStats(manager), + this.querySyncHealthSummary(manager), + ]); + + return { + users: userStats, + platform: platformStats, + ai: aiStats, + content: contentStats, + syncHealth: syncHealthStats, + }; + } + + private async queryUserStats(manager: any) { + const rows = await manager.query(` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE email_verified_at IS NOT NULL)::int AS verified, + COUNT(*) FILTER (WHERE email_verified_at IS NULL)::int AS unverified, + COUNT(*) FILTER (WHERE user_type = 'admin')::int AS admins + FROM dra_users_platform + `); + const r = rows[0] || {}; + return { + total: r.total || 0, + verified: r.verified || 0, + unverified: r.unverified || 0, + admins: r.admins || 0, + }; + } + + private async queryPlatformStats(manager: any) { + const rows = await manager.query(` + SELECT + (SELECT COUNT(*)::int FROM dra_projects) AS projects, + (SELECT COUNT(*)::int FROM dra_data_sources) AS data_sources, + (SELECT COUNT(*)::int FROM dra_dashboards) AS dashboards, + (SELECT COUNT(*)::int FROM dra_data_models) AS data_models + `); + const r = rows[0] || {}; + return { + projects: r.projects || 0, + dataSources: r.data_sources || 0, + dashboards: r.dashboards || 0, + dataModels: r.data_models || 0, + }; + } + + private async queryAIStats(manager: any) { + const rows = await manager.query(` + SELECT + (SELECT COUNT(*)::int FROM dra_ai_data_model_conversations) AS conversations, + (SELECT COUNT(*)::int FROM dra_ai_data_model_messages) AS messages + `); + const r = rows[0] || {}; + + let activeRedisSessions = 0; + try { + const redis = getRedisClient(); + const keys = await redis.keys('dra:ai:*session*'); + activeRedisSessions = keys.length; + } catch { + activeRedisSessions = 0; + } + + return { + totalConversations: r.conversations || 0, + totalMessages: r.messages || 0, + activeRedisSessions, + }; + } + + private async queryContentStats(manager: any) { + const rows = await manager.query(` + SELECT + (SELECT COUNT(*)::int FROM dra_articles) AS articles, + (SELECT COUNT(*)::int FROM dra_articles WHERE publish_status = 'published') AS published, + (SELECT COUNT(*)::int FROM dra_articles WHERE publish_status = 'draft') AS draft, + (SELECT COUNT(*)::int FROM dra_categories) AS categories, + (SELECT COUNT(*)::int FROM dra_sitemap_entries) AS sitemap_urls + `); + const r = rows[0] || {}; + return { + articles: r.articles || 0, + publishedArticles: r.published || 0, + draftArticles: r.draft || 0, + categories: r.categories || 0, + sitemapUrls: r.sitemap_urls || 0, + }; + } + + private async querySyncHealthSummary(manager: any) { + const rows = await manager.query(` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER ( + WHERE data_type NOT IN ('postgresql','mysql','mariadb','mongodb','csv','excel','pdf') + AND ( + connection_details->'api_connection_details'->'api_config'->>'last_sync' IS NULL + OR connection_details->'api_connection_details'->'api_config'->>'last_sync' = 'null' + ) + )::int AS never_synced, + 0::int AS failed + FROM dra_data_sources + `); + const r = rows[0] || {}; + return { + totalSources: r.total || 0, + failedSources: r.failed || 0, + neverSynced: r.never_synced || 0, + }; + } + + async getSyncHealthData(): Promise { + const manager = AppDataSource.manager; + const rows = await manager.query(` + SELECT + ds.id, + ds.name, + ds.data_type, + ds.created_at, + u.email AS owner_email, + ds.connection_details->'api_connection_details'->'api_config'->>'last_sync' AS last_sync + FROM dra_data_sources ds + LEFT JOIN dra_users_platform u ON ds.users_platform_id = u.id + ORDER BY ds.id DESC + LIMIT 200 + `); + + return rows.map((r: any) => { + const isFileOrDb = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'csv', 'excel', 'pdf'].includes(r.data_type); + let status: 'synced' | 'failed' | 'never' = 'synced'; + if (!isFileOrDb) { + if (!r.last_sync || r.last_sync === 'null') { + status = 'never'; + } else { + const lastSyncDate = new Date(r.last_sync); + const hoursSinceSync = (Date.now() - lastSyncDate.getTime()) / 3600000; + status = hoursSinceSync > 72 ? 'failed' : 'synced'; + } + } + return { + id: r.id, + name: r.name, + data_type: r.data_type, + owner_email: r.owner_email || 'Unknown', + last_sync: r.last_sync || null, + created_at: r.created_at || null, + status, + }; + }); + } + + async getSystemHealth(): Promise { + let dbHealthy = false; + let redisHealthy = false; + + try { + await AppDataSource.manager.query('SELECT 1'); + dbHealthy = true; + } catch { + dbHealthy = false; + } + + try { + const redis = getRedisClient(); + await redis.ping(); + redisHealthy = true; + } catch { + redisHealthy = false; + } + + let schedulerHealth = { + enabled: false, + isRunning: false, + nextRun: null as Date | null, + lastRun: null as Date | null, + }; + let backupStats = null; + try { + const schedulerStatus = await ScheduledBackupService.getInstance().getStatus(); + schedulerHealth = { + enabled: schedulerStatus.scheduler_enabled, + isRunning: schedulerStatus.is_running, + nextRun: schedulerStatus.next_run, + lastRun: schedulerStatus.last_run, + }; + const stats = await ScheduledBackupProcessor.getInstance().getBackupStats(); + backupStats = { + totalRuns: stats.total_runs, + successfulRuns: stats.successful_runs, + failedRuns: stats.failed_runs, + totalSizeBytes: stats.total_backup_size_bytes, + }; + } catch { + // Backup service may not be configured + } + + return { + database: dbHealthy, + redis: redisHealthy, + backupScheduler: schedulerHealth, + backupStats, + }; + } + + async getTimeSeriesData(metric: string, days: number): Promise { + const manager = AppDataSource.manager; + const safeMetric = metric.replace(/[^a-z_]/gi, ''); + const safeDays = Math.min(Math.max(parseInt(String(days), 10) || 30, 1), 365); + + const tableMap: Record = { + signups: { table: 'dra_users_platform', dateCol: 'email_verified_at' }, + projects: { table: 'dra_projects', dateCol: 'created_at' }, + data_sources: { table: 'dra_data_sources', dateCol: 'created_at' }, + ai_messages: { table: 'dra_ai_data_model_messages', dateCol: 'created_at' }, + ai_conversations: { table: 'dra_ai_data_model_conversations', dateCol: 'created_at' }, + cancellations: { table: 'dra_account_cancellations', dateCol: 'created_at' }, + }; + + const config = tableMap[safeMetric]; + if (!config) return []; + + try { + const rows = await manager.query(` + SELECT + TO_CHAR(DATE_TRUNC('day', ${config.dateCol}), 'YYYY-MM-DD') AS date, + COUNT(*)::int AS count + FROM ${config.table} + WHERE ${config.dateCol} >= NOW() - INTERVAL '${safeDays} days' + AND ${config.dateCol} IS NOT NULL + ${config.filter ? `AND ${config.filter}` : ''} + GROUP BY 1 + ORDER BY 1 + `); + return rows.map((r: any) => ({ date: r.date, count: r.count })); + } catch { + return []; + } + } + + async getDataSourceTypeBreakdown(): Promise<{ data_type: string; count: number }[]> { + const manager = AppDataSource.manager; + const rows = await manager.query(` + SELECT data_type, COUNT(*)::int AS count + FROM dra_data_sources + GROUP BY data_type + ORDER BY count DESC + `); + return rows.map((r: any) => ({ data_type: r.data_type, count: r.count })); + } +} diff --git a/backend/src/processors/ScheduledBackupProcessor.ts b/backend/src/processors/ScheduledBackupProcessor.ts index f0be56b0..19548720 100644 --- a/backend/src/processors/ScheduledBackupProcessor.ts +++ b/backend/src/processors/ScheduledBackupProcessor.ts @@ -4,6 +4,7 @@ import { DRAScheduledBackupRun } from '../models/DRAScheduledBackupRun.js'; import { EBackupTriggerType } from '../types/EBackupTriggerType.js'; import { EBackupRunStatus } from '../types/EBackupRunStatus.js'; import { IScheduledBackupRun, IBackupStats } from '../interfaces/IScheduledBackupRun.js'; +import { SocketIODriver } from '../drivers/SocketIODriver.js'; /** * Processor for Scheduled Backup Run Management @@ -85,6 +86,13 @@ export class ScheduledBackupProcessor { await manager.update(DRAScheduledBackupRun, { id: runId }, updateData); console.log(`✅ Updated backup run #${runId} to status: ${status}`); + + if (status === EBackupRunStatus.COMPLETED || status === EBackupRunStatus.FAILED) { + SocketIODriver.getInstance().emitToRoom('admin-dashboard', 'admin-stats-update', { + type: 'backup_complete', + status, + }); + } resolve(); } catch (error) { diff --git a/backend/src/processors/UserManagementProcessor.ts b/backend/src/processors/UserManagementProcessor.ts index 1cb5052c..ec8e9021 100644 --- a/backend/src/processors/UserManagementProcessor.ts +++ b/backend/src/processors/UserManagementProcessor.ts @@ -8,6 +8,7 @@ import bcrypt from 'bcryptjs'; import { UtilityService } from "../services/UtilityService.js"; import { DRAVerificationCode } from "../models/DRAVerificationCode.js"; import { EmailService } from "../services/EmailService.js"; +import { SocketIODriver } from "../drivers/SocketIODriver.js"; import { IUserManagement } from "../types/IUserManagement.js"; import { IUserUpdate } from "../types/IUserUpdate.js"; import { IUserCreation } from "../types/IUserCreation.js"; @@ -272,6 +273,7 @@ export class UserManagementProcessor { email_verified_at: newUser.email_verified_at, unsubscribe_from_emails_at: newUser.unsubscribe_from_emails_at }; + SocketIODriver.getInstance().emitToRoom('admin-dashboard', 'admin-stats-update', { type: 'user_created', delta: 1 }); return resolve(createdUser); } catch (error) { console.error('Error creating user:', error); diff --git a/backend/src/routes/admin/stats.ts b/backend/src/routes/admin/stats.ts new file mode 100644 index 00000000..0c5cf527 --- /dev/null +++ b/backend/src/routes/admin/stats.ts @@ -0,0 +1,90 @@ +import { Router, Request, Response } from 'express'; +import { validateJWT } from '../../middleware/authenticate.js'; +import { EUserType } from '../../types/EUserType.js'; +import { AdminStatsProcessor } from '../../processors/AdminStatsProcessor.js'; + +const router = Router(); +const processor = AdminStatsProcessor.getInstance(); + +async function requireAdmin(req: any, res: any, next: any) { + const tokenDetails = req.tokenDetails || req.body.tokenDetails; + if (!tokenDetails || tokenDetails.user_type !== EUserType.ADMIN) { + return res.status(403).json({ success: false, message: 'Admin access required' }); + } + next(); +} + +/** + * GET /admin/stats/overview + * Returns platform-wide aggregate counts for the admin dashboard. + */ +router.get('/overview', validateJWT, requireAdmin, async (req: Request, res: Response) => { + try { + const data = await processor.getOverviewStats(); + res.json({ success: true, data }); + } catch (error: any) { + console.error('[AdminStats] Error fetching overview:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /admin/stats/timeseries?metric=signups&days=30 + * Returns daily counts for the given metric over the last N days. + * Valid metrics: signups | projects | data_sources | ai_messages | ai_conversations | cancellations + */ +router.get('/timeseries', validateJWT, requireAdmin, async (req: Request, res: Response) => { + try { + const metric = String(req.query.metric || 'signups'); + const days = parseInt(String(req.query.days || '30'), 10); + const data = await processor.getTimeSeriesData(metric, days); + res.json({ success: true, data }); + } catch (error: any) { + console.error('[AdminStats] Error fetching timeseries:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /admin/stats/sync-health + * Returns per-data-source sync status rows. + */ +router.get('/sync-health', validateJWT, requireAdmin, async (req: Request, res: Response) => { + try { + const data = await processor.getSyncHealthData(); + res.json({ success: true, data }); + } catch (error: any) { + console.error('[AdminStats] Error fetching sync health:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /admin/stats/system-health + * Returns DB, Redis, and backup scheduler health probes. + */ +router.get('/system-health', validateJWT, requireAdmin, async (req: Request, res: Response) => { + try { + const data = await processor.getSystemHealth(); + res.json({ success: true, data }); + } catch (error: any) { + console.error('[AdminStats] Error fetching system health:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * GET /admin/stats/datasource-types + * Returns data source type breakdown for donut chart. + */ +router.get('/datasource-types', validateJWT, requireAdmin, async (req: Request, res: Response) => { + try { + const data = await processor.getDataSourceTypeBreakdown(); + res.json({ success: true, data }); + } catch (error: any) { + console.error('[AdminStats] Error fetching datasource types:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +export default router; diff --git a/frontend/components/AdminChart.vue b/frontend/components/AdminChart.vue new file mode 100644 index 00000000..9345121f --- /dev/null +++ b/frontend/components/AdminChart.vue @@ -0,0 +1,162 @@ + + + diff --git a/frontend/components/AdminStatCard.vue b/frontend/components/AdminStatCard.vue new file mode 100644 index 00000000..29d5c301 --- /dev/null +++ b/frontend/components/AdminStatCard.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/composables/useAdminStats.ts b/frontend/composables/useAdminStats.ts new file mode 100644 index 00000000..c29a459d --- /dev/null +++ b/frontend/composables/useAdminStats.ts @@ -0,0 +1,152 @@ +import { ref, onMounted, onUnmounted } from 'vue'; +import type { + IAdminOverviewStats, + IDataSourceSyncRow, + ISystemHealthStatus, + ITimeSeriesPoint, +} from '~/types/admin/stats'; + +export const useAdminStats = () => { + const config = useRuntimeConfig(); + const { $socket } = useNuxtApp() as any; + + const overviewStats = ref(null); + const syncHealthData = ref([]); + const systemHealth = ref(null); + const isLoading = ref(false); + const error = ref(null); + + const authHeaders = (): Record => { + const token = getAuthToken(); + if (!token) throw new Error('Authentication required'); + return { + Authorization: `Bearer ${token}`, + 'Authorization-Type': 'auth', + 'Content-Type': 'application/json', + }; + }; + + const fetchOverview = async () => { + const res = await $fetch<{ success: boolean; data: IAdminOverviewStats }>( + `${config.public.apiBase}/admin/stats/overview`, + { headers: authHeaders() } + ); + if (res.success) overviewStats.value = res.data; + }; + + const fetchSyncHealth = async () => { + const res = await $fetch<{ success: boolean; data: IDataSourceSyncRow[] }>( + `${config.public.apiBase}/admin/stats/sync-health`, + { headers: authHeaders() } + ); + if (res.success) syncHealthData.value = res.data; + }; + + const fetchSystemHealth = async () => { + const res = await $fetch<{ success: boolean; data: ISystemHealthStatus }>( + `${config.public.apiBase}/admin/stats/system-health`, + { headers: authHeaders() } + ); + if (res.success) systemHealth.value = res.data; + }; + + const refreshStats = async () => { + try { + await Promise.all([fetchOverview(), fetchSyncHealth(), fetchSystemHealth()]); + } catch (err: any) { + console.error('[useAdminStats] Refresh failed:', err); + error.value = err.message; + } + }; + + const loadAll = async () => { + isLoading.value = true; + error.value = null; + try { + await refreshStats(); + } finally { + isLoading.value = false; + } + }; + + let statsUpdateHandler: (() => void) | null = null; + + onMounted(() => { + if (!import.meta.client) return; + loadAll(); + + if ($socket) { + $socket.emit('join-admin-room'); + statsUpdateHandler = () => { + refreshStats(); + }; + $socket.on('admin-stats-update', statsUpdateHandler); + } + }); + + onUnmounted(() => { + if (!import.meta.client) return; + if ($socket && statsUpdateHandler) { + $socket.off('admin-stats-update', statsUpdateHandler); + } + }); + + return { + overviewStats, + syncHealthData, + systemHealth, + isLoading, + error, + refreshStats, + }; +}; + +export const useAdminTimeSeries = (metric: string, days = 30) => { + const config = useRuntimeConfig(); + const data = ref([]); + const dsTypeBreakdown = ref<{ data_type: string; count: number }[]>([]); + const isLoading = ref(false); + + const authHeaders = (): Record => { + const token = getAuthToken(); + if (!token) throw new Error('Authentication required'); + return { + Authorization: `Bearer ${token}`, + 'Authorization-Type': 'auth', + }; + }; + + const fetchTimeSeries = async () => { + const res = await $fetch<{ success: boolean; data: ITimeSeriesPoint[] }>( + `${config.public.apiBase}/admin/stats/timeseries?metric=${metric}&days=${days}`, + { headers: authHeaders() } + ); + if (res.success) data.value = res.data; + }; + + const fetchDsTypeBreakdown = async () => { + const res = await $fetch<{ success: boolean; data: { data_type: string; count: number }[] }>( + `${config.public.apiBase}/admin/stats/datasource-types`, + { headers: authHeaders() } + ); + if (res.success) dsTypeBreakdown.value = res.data; + }; + + onMounted(async () => { + if (!import.meta.client) return; + isLoading.value = true; + try { + if (metric === 'datasource_types') { + await fetchDsTypeBreakdown(); + } else { + await fetchTimeSeries(); + } + } catch (err) { + console.error('[useAdminTimeSeries] Load failed:', err); + } finally { + isLoading.value = false; + } + }); + + return { data, dsTypeBreakdown, isLoading }; +}; diff --git a/frontend/pages/admin/index.vue b/frontend/pages/admin/index.vue index 5c9a1e0b..7478f51f 100644 --- a/frontend/pages/admin/index.vue +++ b/frontend/pages/admin/index.vue @@ -1,15 +1,608 @@ - +