Add Database Size single metric and time series history chart to Admin Dashboard
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user