Add Database Size single metric and time series history chart to Admin Dashboard

This commit is contained in:
2026-06-06 21:45:19 +02:00
parent 6848390ffa
commit 9ad3c2cf38
3 changed files with 92 additions and 6 deletions
+15 -3
View File
@@ -7,12 +7,22 @@ import {
type AdminTimeSeriesResponse, type AdminTimeSeriesResponse,
type AdminTimeBucket type AdminTimeBucket
} from '../services/adminApi.js' } from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react' import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
function formatNumber(value: number): string { function formatNumber(value: number): string {
return value.toLocaleString() return value.toLocaleString()
} }
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined || bytes === null) return '—'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const num = bytes / Math.pow(k, i)
return `${num.toFixed(1)} ${sizes[i]}`
}
function KpiCard({ function KpiCard({
icon, icon,
label, label,
@@ -20,14 +30,14 @@ function KpiCard({
}: { }: {
icon: ReactNode icon: ReactNode
label: string label: string
value: number value: number | string
}) { }) {
return ( return (
<div className="stats-kpi-card glass"> <div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div> <div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body"> <div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span> <span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">{formatNumber(value)}</span> <span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
</div> </div>
</div> </div>
) )
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
label="Einträge mit AI-Zusammenfassung" label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries} value={summary.aiSummaryEntries}
/> />
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
</section> </section>
<section className="admin-controls"> <section className="admin-controls">
@@ -233,6 +244,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} /> <TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} /> <TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} /> <TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
</section> </section>
</main> </main>
</div> </div>
+1
View File
@@ -16,6 +16,7 @@ export interface AdminSummary {
totalCollaborations: number totalCollaborations: number
totalInvitations: number totalInvitations: number
aiSummaryEntries: number aiSummaryEntries: number
dbSize: number
} }
export type AdminTimeBucket = 'day' | 'week' | 'month' export type AdminTimeBucket = 'day' | 'week' | 'month'
+76 -3
View File
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
prisma.aiSummaryUsage.count() prisma.aiSummaryUsage.count()
]) ])
const rawDbSize = await prisma.$queryRaw<[{ size: string }]>`
SELECT pg_database_size(current_database())::text as size
`
const dbSize = Number(rawDbSize[0]?.size || '0')
res.json({ res.json({
totalUsers, totalUsers,
totalLogbooks, totalLogbooks,
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
totalGpsTracks, totalGpsTracks,
totalCollaborations, totalCollaborations,
totalInvitations, totalInvitations,
aiSummaryEntries aiSummaryEntries,
dbSize
}) })
} catch (error: unknown) { } catch (error: unknown) {
console.error('admin/summary error', error) console.error('admin/summary error', error)
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
const since = new Date() const since = new Date()
since.setUTCDate(since.getUTCDate() - windowDays) since.setUTCDate(since.getUTCDate() - windowDays)
const [users, logbooks, photos] = await Promise.all([ const [users, logbooks, photos, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where: { createdAt: { gte: since } }, where: { createdAt: { gte: since } },
select: { createdAt: true } select: { createdAt: true }
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
prisma.photoPayload.findMany({ prisma.photoPayload.findMany({
where: { updatedAt: { gte: since } }, where: { updatedAt: { gte: since } },
select: { updatedAt: true } select: { updatedAt: true }
}),
prisma.$queryRaw<[{ size: string }]>`
SELECT pg_database_size(current_database())::text as size
`,
prisma.photoPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.voiceMemoPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.gpsTrackPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.entryPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}) })
]) ])
const dbSizeVal = Number(dbSizeRaw[0]?.size || '0')
const payloads: { date: Date; size: number }[] = []
for (const p of photosSize) {
payloads.push({ date: p.updatedAt, size: p.encryptedData.length })
}
for (const v of voiceSize) {
payloads.push({ date: v.updatedAt, size: v.encryptedData.length })
}
for (const g of tracksSize) {
payloads.push({ date: g.updatedAt, size: g.encryptedData.length })
}
for (const e of entriesSize) {
payloads.push({ date: e.updatedAt, size: e.encryptedData.length })
}
const totalPayloadsSize = payloads.reduce((acc, p) => acc + p.size, 0)
const baseDbSize = Math.max(0, dbSizeVal - totalPayloadsSize)
payloads.sort((a, b) => a.date.getTime() - b.date.getTime())
// Generate complete list of date keys for the window
const dateKeys: string[] = []
const current = new Date(since)
const todayStr = bucketDate(new Date(), bucket)
while (true) {
const key = bucketDate(current, bucket)
if (!dateKeys.includes(key)) {
dateKeys.push(key)
}
if (key >= todayStr) break
current.setUTCDate(current.getUTCDate() + 1)
}
const dbSizePoints = dateKeys.map((key) => {
let sizeSum = 0
for (const p of payloads) {
if (bucketDate(p.date, bucket) <= key) {
sizeSum += p.size
} else {
break
}
}
const totalBytes = baseDbSize + sizeSum
const sizeInMb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10
return { date: key, count: sizeInMb }
})
function aggregate(dates: Date[], metric: string): TimeSeries { function aggregate(dates: Date[], metric: string): TimeSeries {
const map = new Map<string, number>() const map = new Map<string, number>()
for (const d of dates) { for (const d of dates) {
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
aggregate( aggregate(
photos.map((p) => p.updatedAt), photos.map((p) => p.updatedAt),
'photos_updated' 'photos_updated'
) ),
{
metric: 'database_size',
points: dbSizePoints
}
] ]
} }