-
Notifications
You must be signed in to change notification settings - Fork 3
fix(admin): address review feedback on admin dashboard stats #359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,5 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { AppDataSource } from '../datasources/PostgresDS.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { DRADataSource } from '../models/DRADataSource.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getRedisClient } from '../config/redis.config.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ScheduledBackupProcessor } from './ScheduledBackupProcessor.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ScheduledBackupService } from '../services/ScheduledBackupService.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -151,8 +152,9 @@ export class AdminStatsProcessor { | |||||||||||||||||||||||||||||||||||||||||||||||||
| let activeRedisSessions = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const redis = getRedisClient(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const keys = await redis.keys('dra:ai:*session*'); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| activeRedisSessions = keys.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| for await (const _key of redis.scanIterator({ MATCH: 'dra:ai:*session*' })) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| activeRedisSessions++; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| activeRedisSessions = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -183,63 +185,63 @@ export class AdminStatsProcessor { | |||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| 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] || {}; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| private async querySyncHealthSummary(_manager: any) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const FILE_DB_TYPES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'csv', 'excel', 'pdf']; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const dataSources = await AppDataSource.manager.find(DRADataSource); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const total = dataSources.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| let neverSynced = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| let failedSources = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const ds of dataSources) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (FILE_DB_TYPES.includes(ds.data_type)) continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+190
to
+197
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const dataSources = await AppDataSource.manager.find(DRADataSource); | |
| const total = dataSources.length; | |
| let neverSynced = 0; | |
| let failedSources = 0; | |
| for (const ds of dataSources) { | |
| if (FILE_DB_TYPES.includes(ds.data_type)) continue; | |
| // Count all data sources cheaply without decrypting connection_details | |
| const total = await AppDataSource.manager.count(DRADataSource); | |
| // Only load the minimal fields needed for sync health, and only for API-integrated sources | |
| const apiDataSources = await AppDataSource.manager | |
| .createQueryBuilder(DRADataSource, 'ds') | |
| .select(['ds.data_type', 'ds.connection_details']) | |
| .where('ds.data_type NOT IN (:...fileTypes)', { fileTypes: FILE_DB_TYPES }) | |
| .getMany(); | |
| let neverSynced = 0; | |
| let failedSources = 0; | |
| for (const ds of apiDataSources) { |
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
querySyncHealthSummary() treats any truthy last_sync value as a valid date. If legacy rows contain the string 'null' (which the previous SQL explicitly checked for), new Date('null') becomes an invalid date and the source won’t be counted as neverSynced or failedSources. Handle 'null' (and other invalid dates) the same way as missing last_sync.
| const lastSync = ds.connection_details?.api_connection_details?.api_config?.last_sync; | |
| if (!lastSync) { | |
| neverSynced++; | |
| } else { | |
| const hoursSinceSync = (Date.now() - new Date(lastSync as any).getTime()) / 3600000; | |
| if (hoursSinceSync > 72) failedSources++; | |
| const lastSyncRaw = ds.connection_details?.api_connection_details?.api_config?.last_sync; | |
| const lastSync = lastSyncRaw ? String(lastSyncRaw) : null; | |
| if (!lastSync || lastSync === 'null') { | |
| // Treat missing or legacy 'null' values as never synced | |
| neverSynced++; | |
| } else { | |
| const parsedDate = new Date(lastSync); | |
| const timestamp = parsedDate.getTime(); | |
| if (Number.isNaN(timestamp)) { | |
| // Invalid dates are also treated as never synced | |
| neverSynced++; | |
| } else { | |
| const hoursSinceSync = (Date.now() - timestamp) / 3600000; | |
| if (hoursSinceSync > 72) failedSources++; | |
| } |
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
owner_email is pulled via (ds as any).users_platform?.email even though DRADataSource already declares a typed users_platform relation and this query is loading it via relations. Dropping the any cast will keep this type-safe and make it easier to spot relation-loading issues at compile time.
| owner_email: (ds as any).users_platform?.email || 'Unknown', | |
| owner_email: ds.users_platform?.email || 'Unknown', |
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getSyncHealthData() serializes timestamps using String(...) (e.g. created_at: String(ds.created_at), last_sync: String(lastSyncRaw)). String(Date) yields a locale/implementation-dependent format; returning a stable ISO-8601 string (e.g. toISOString()) is safer for clients and consistent with other parts of the backend.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,22 +34,48 @@ const authHeaders = () => { | |
| const fetchAllSeries = async () => { | ||
| isSeriesLoading.value = true; | ||
| try { | ||
| const [signups, projects, ai, cancellations, dsTypes] = await Promise.all([ | ||
| const results = await Promise.allSettled([ | ||
| $fetch<any>(`${config.public.apiBase}/admin/stats/timeseries?metric=signups&days=30`, { headers: authHeaders() }), | ||
| $fetch<any>(`${config.public.apiBase}/admin/stats/timeseries?metric=projects&days=30`, { headers: authHeaders() }), | ||
| $fetch<any>(`${config.public.apiBase}/admin/stats/timeseries?metric=ai_messages&days=30`, { headers: authHeaders() }), | ||
| $fetch<any>(`${config.public.apiBase}/admin/stats/timeseries?metric=cancellations&days=30`, { headers: authHeaders() }), | ||
| $fetch<any>(`${config.public.apiBase}/admin/stats/datasource-types`, { headers: authHeaders() }), | ||
|
Comment on lines
34
to
42
|
||
| ]); | ||
| if (signups.success) signupSeries.value = signups.data; | ||
| if (projects.success) projectSeries.value = projects.data; | ||
| if (ai.success) aiSeries.value = ai.data; | ||
| if (cancellations.success) cancellationSeries.value = cancellations.data; | ||
| if (dsTypes.success) { | ||
| dsTypeSeries.value = dsTypes.data.map((d: any) => ({ label: d.data_type, value: d.count })); | ||
|
|
||
| const [signupsResult, projectsResult, aiResult, cancellationsResult, dsTypesResult] = results; | ||
|
|
||
| if (signupsResult.status === 'fulfilled' && signupsResult.value.success) { | ||
| signupSeries.value = signupsResult.value.data; | ||
| } else if (signupsResult.status === 'rejected') { | ||
| console.error('[AdminDashboard] Failed to load signups time-series:', signupsResult.reason); | ||
| } | ||
|
|
||
| if (projectsResult.status === 'fulfilled' && projectsResult.value.success) { | ||
| projectSeries.value = projectsResult.value.data; | ||
| } else if (projectsResult.status === 'rejected') { | ||
| console.error('[AdminDashboard] Failed to load projects time-series:', projectsResult.reason); | ||
| } | ||
|
|
||
| if (aiResult.status === 'fulfilled' && aiResult.value.success) { | ||
| aiSeries.value = aiResult.value.data; | ||
| } else if (aiResult.status === 'rejected') { | ||
| console.error('[AdminDashboard] Failed to load AI messages time-series:', aiResult.reason); | ||
| } | ||
|
|
||
| if (cancellationsResult.status === 'fulfilled' && cancellationsResult.value.success) { | ||
| cancellationSeries.value = cancellationsResult.value.data; | ||
| } else if (cancellationsResult.status === 'rejected') { | ||
| console.error('[AdminDashboard] Failed to load cancellations time-series:', cancellationsResult.reason); | ||
| } | ||
|
|
||
| if (dsTypesResult.status === 'fulfilled' && dsTypesResult.value.success) { | ||
| dsTypeSeries.value = dsTypesResult.value.data.map((d: any) => ({ | ||
| label: d.data_type, | ||
| value: d.count, | ||
| })); | ||
| } else if (dsTypesResult.status === 'rejected') { | ||
| console.error('[AdminDashboard] Failed to load data source type stats:', dsTypesResult.reason); | ||
| } | ||
| } catch (err) { | ||
| console.error('[AdminDashboard] Failed to load time-series:', err); | ||
| } finally { | ||
| isSeriesLoading.value = false; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
querySyncHealthSummary(_manager)now ignores the passedmanagerand usesAppDataSource.managerdirectly. This makes it harder to reuse inside transactions and reduces testability/mocking. Prefer using the injectedmanager(i.e.,manager.find(DRADataSource, ...)) or remove the unused parameter to avoid confusion.