feat: add admin dashboard with usage stats
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,6 +36,10 @@ ORIGIN=http://localhost:5173
|
|||||||
# Generate: openssl rand -base64 48
|
# Generate: openssl rand -base64 48
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
|
||||||
|
# Example: ADMIN_USER_IDS=e6bcd493-80a0-400f-8a27-43c9cdce6e29,11111111-2222-3333-4444-555555555555
|
||||||
|
ADMIN_USER_IDS=
|
||||||
|
|
||||||
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
||||||
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
|
|||||||
@@ -4919,6 +4919,125 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--app-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--app-header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--app-accent-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subtitle {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-kpi-grid {
|
||||||
|
margin-top: 0;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: var(--app-radius-card);
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
background: var(--app-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-control-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-control-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-control-buttons .btn {
|
||||||
|
width: auto;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-charts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-left {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-kpi-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.stats-consumption-chart .stats-bar-column--grouped {
|
.stats-consumption-chart .stats-bar-column--grouped {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|||||||
+19
-1
@@ -36,6 +36,7 @@ import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
|||||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||||
import DemoViewer from './components/DemoViewer.tsx'
|
import DemoViewer from './components/DemoViewer.tsx'
|
||||||
|
import AdminDashboard from './admin/AdminDashboard.tsx'
|
||||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||||
import AppFooter from './components/AppFooter.tsx'
|
import AppFooter from './components/AppFooter.tsx'
|
||||||
@@ -92,6 +93,7 @@ function App() {
|
|||||||
|
|
||||||
// Public demo mode (no account required)
|
// Public demo mode (no account required)
|
||||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
||||||
|
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
||||||
|
|
||||||
const syncQueueCount = useLiveQuery(
|
const syncQueueCount = useLiveQuery(
|
||||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||||
@@ -199,6 +201,13 @@ function App() {
|
|||||||
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
|
|
||||||
|
if (path.startsWith('/admin')) {
|
||||||
|
setIsAdminRoute(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdminRoute(false)
|
||||||
|
|
||||||
if (path === '/demo') {
|
if (path === '/demo') {
|
||||||
setIsDemoMode(true)
|
setIsDemoMode(true)
|
||||||
setIsViewerMode(false)
|
setIsViewerMode(false)
|
||||||
@@ -249,7 +258,7 @@ function App() {
|
|||||||
|
|
||||||
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
||||||
const enforceUnlockedSession = useCallback(() => {
|
const enforceUnlockedSession = useCallback(() => {
|
||||||
if (isViewerMode || isDemoMode || isAcceptingInvite) return
|
if (isViewerMode || isDemoMode || isAcceptingInvite || isAdminRoute) return
|
||||||
// Require full local session (incl. userId) so API calls are not left headless.
|
// Require full local session (incl. userId) so API calls are not left headless.
|
||||||
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
||||||
clearAuthenticatedAppState()
|
clearAuthenticatedAppState()
|
||||||
@@ -259,6 +268,7 @@ function App() {
|
|||||||
isViewerMode,
|
isViewerMode,
|
||||||
isDemoMode,
|
isDemoMode,
|
||||||
isAcceptingInvite,
|
isAcceptingInvite,
|
||||||
|
isAdminRoute,
|
||||||
clearAuthenticatedAppState
|
clearAuthenticatedAppState
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -524,6 +534,14 @@ function App() {
|
|||||||
syncRouteFromLocation()
|
syncRouteFromLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAdminRoute) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'contents' }}>
|
||||||
|
<AdminDashboard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'contents' }}>
|
<div style={{ display: 'contents' }}>
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
fetchAdminMe,
|
||||||
|
fetchAdminSummary,
|
||||||
|
fetchAdminTimeSeries,
|
||||||
|
type AdminSummary,
|
||||||
|
type AdminTimeSeriesResponse,
|
||||||
|
type AdminTimeBucket
|
||||||
|
} from '../services/adminApi.js'
|
||||||
|
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="stats-kpi-card glass">
|
||||||
|
<div className="stats-kpi-icon">{icon}</div>
|
||||||
|
<div className="stats-kpi-body">
|
||||||
|
<span className="stats-kpi-label">{label}</span>
|
||||||
|
<span className="stats-kpi-value">{formatNumber(value)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeSeriesChart({
|
||||||
|
title,
|
||||||
|
seriesKey,
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
seriesKey: string
|
||||||
|
data: AdminTimeSeriesResponse | null
|
||||||
|
}) {
|
||||||
|
if (!data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const metric = data.series.find((s) => s.metric === seriesKey)
|
||||||
|
if (!metric || metric.points.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="form-card glass">
|
||||||
|
<div className="form-header">
|
||||||
|
<BarChart2 className="form-icon" />
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="dashboard-status-msg">Keine Daten im gewählten Zeitraum.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = metric.points.reduce((acc, p) => (p.count > acc ? p.count : acc), 0) || 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-card glass">
|
||||||
|
<div className="form-header">
|
||||||
|
<BarChart2 className="form-icon" />
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="stats-bar-chart" role="img" aria-label={title}>
|
||||||
|
{metric.points.map((point) => {
|
||||||
|
const heightPct = Math.max(2, (point.count / max) * 100)
|
||||||
|
return (
|
||||||
|
<div key={point.date} className="stats-bar-column" title={`${point.date}: ${point.count}`}>
|
||||||
|
<span className="stats-bar-value">{point.count > 0 ? String(point.count) : ''}</span>
|
||||||
|
<div className="stats-bar-track">
|
||||||
|
<div className="stats-bar stats-bar--distance" style={{ height: `${heightPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="stats-bar-label">{point.date.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [summary, setSummary] = useState<AdminSummary | null>(null)
|
||||||
|
const [timeSeries, setTimeSeries] = useState<AdminTimeSeriesResponse | null>(null)
|
||||||
|
const [bucket, setBucket] = useState<AdminTimeBucket>('day')
|
||||||
|
const [windowDays, setWindowDays] = useState(90)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
await fetchAdminMe()
|
||||||
|
const [summaryRes, tsRes] = await Promise.all([
|
||||||
|
fetchAdminSummary(),
|
||||||
|
fetchAdminTimeSeries({ bucket, windowDays })
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setSummary(summaryRes)
|
||||||
|
setTimeSeries(tsRes)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error && err.message ? err.message : 'Fehler beim Laden des Admin-Dashboards'
|
||||||
|
setError(message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [bucket, windowDays])
|
||||||
|
|
||||||
|
if (loading && !summary) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<p className="dashboard-status-msg">Admin-Dashboard wird geladen…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<header className="admin-header">
|
||||||
|
<button type="button" className="btn-back" onClick={() => { window.location.href = '/' }}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
Zur App
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<p className="dashboard-status-msg">{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<p className="dashboard-status-msg">Keine Admin-Daten verfügbar.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<header className="admin-header">
|
||||||
|
<div className="admin-header-left">
|
||||||
|
<button type="button" className="btn-back" onClick={() => { window.location.href = '/' }}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
Zur App
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-title">Admin-Dashboard</h1>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
Übersicht über Nutzung und Wachstum von Kapteins Daagbok.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="admin-main">
|
||||||
|
<section className="stats-kpi-grid admin-kpi-grid">
|
||||||
|
<KpiCard icon={<Users size={20} />} label="Registrierte Benutzer" value={summary.totalUsers} />
|
||||||
|
<KpiCard icon={<Bookmark size={20} />} label="Logbücher" value={summary.totalLogbooks} />
|
||||||
|
<KpiCard icon={<Image size={20} />} label="Fotos" value={summary.totalPhotos} />
|
||||||
|
<KpiCard icon={<Mic size={20} />} label="Sprachmemos" value={summary.totalVoiceMemos} />
|
||||||
|
<KpiCard icon={<MapPin size={20} />} label="GPS-Tracks" value={summary.totalGpsTracks} />
|
||||||
|
<KpiCard
|
||||||
|
icon={<BarChart2 size={20} />}
|
||||||
|
label="Einträge mit AI-Zusammenfassung"
|
||||||
|
value={summary.aiSummaryEntries}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-controls">
|
||||||
|
<div className="admin-control-group">
|
||||||
|
<span className="admin-control-label">Zeitraum</span>
|
||||||
|
<div className="admin-control-buttons">
|
||||||
|
{[30, 90, 365].map((days) => (
|
||||||
|
<button
|
||||||
|
key={days}
|
||||||
|
type="button"
|
||||||
|
className={days === windowDays ? 'btn primary' : 'btn secondary'}
|
||||||
|
onClick={() => setWindowDays(days)}
|
||||||
|
>
|
||||||
|
{days} Tage
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-control-group">
|
||||||
|
<span className="admin-control-label">Aggregation</span>
|
||||||
|
<div className="admin-control-buttons">
|
||||||
|
{(['day', 'week', 'month'] as AdminTimeBucket[]).map((b) => (
|
||||||
|
<button
|
||||||
|
key={b}
|
||||||
|
type="button"
|
||||||
|
className={b === bucket ? 'btn primary' : 'btn secondary'}
|
||||||
|
onClick={() => setBucket(b)}
|
||||||
|
>
|
||||||
|
{b === 'day' ? 'Tag' : b === 'week' ? 'Woche' : 'Monat'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-charts-grid">
|
||||||
|
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
|
||||||
|
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
|
||||||
|
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { apiJson } from './api.js'
|
||||||
|
|
||||||
|
const ADMIN_BASE = '/api/admin'
|
||||||
|
|
||||||
|
export interface AdminMe {
|
||||||
|
isAdmin: boolean
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSummary {
|
||||||
|
totalUsers: number
|
||||||
|
totalLogbooks: number
|
||||||
|
totalPhotos: number
|
||||||
|
totalVoiceMemos: number
|
||||||
|
totalGpsTracks: number
|
||||||
|
totalCollaborations: number
|
||||||
|
totalInvitations: number
|
||||||
|
aiSummaryEntries: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
||||||
|
|
||||||
|
export interface AdminTimeSeriesPoint {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTimeSeriesMetric {
|
||||||
|
metric: string
|
||||||
|
points: AdminTimeSeriesPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTimeSeriesResponse {
|
||||||
|
bucket: AdminTimeBucket
|
||||||
|
windowDays: number
|
||||||
|
series: AdminTimeSeriesMetric[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminMe(): Promise<AdminMe> {
|
||||||
|
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminSummary(): Promise<AdminSummary> {
|
||||||
|
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminTimeSeries(
|
||||||
|
params: { bucket?: AdminTimeBucket; windowDays?: number } = {}
|
||||||
|
): Promise<AdminTimeSeriesResponse> {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params.bucket) {
|
||||||
|
search.set('bucket', params.bucket)
|
||||||
|
}
|
||||||
|
if (params.windowDays && Number.isFinite(params.windowDays)) {
|
||||||
|
search.set('window', String(params.windowDays))
|
||||||
|
}
|
||||||
|
const query = search.toString()
|
||||||
|
const url = query ? `${ADMIN_BASE}/timeseries?${query}` : `${ADMIN_BASE}/timeseries`
|
||||||
|
return await apiJson<AdminTimeSeriesResponse>(url)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const ADMIN_ENV_KEY = 'ADMIN_USER_IDS'
|
||||||
|
|
||||||
|
export function getAdminUserIds(): Set<string> {
|
||||||
|
const raw = process.env[ADMIN_ENV_KEY]
|
||||||
|
if (!raw) {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = raw
|
||||||
|
.split(',')
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter((id) => id.length > 0)
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminUserId(userId: string): boolean {
|
||||||
|
const adminIds = getAdminUserIds()
|
||||||
|
return adminIds.has(userId)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import pushRouter from './routes/push.js'
|
|||||||
import weatherRouter from './routes/weather.js'
|
import weatherRouter from './routes/weather.js'
|
||||||
import aiRouter from './routes/ai.js'
|
import aiRouter from './routes/ai.js'
|
||||||
import feedbackRouter from './routes/feedback.js'
|
import feedbackRouter from './routes/feedback.js'
|
||||||
|
import adminRouter from './routes/admin.js'
|
||||||
import { prisma } from './db.js'
|
import { prisma } from './db.js'
|
||||||
import { buildCorsOptions } from './cors.js'
|
import { buildCorsOptions } from './cors.js'
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ export function createApp(): express.Express {
|
|||||||
app.use('/api/weather', weatherRouter)
|
app.use('/api/weather', weatherRouter)
|
||||||
app.use('/api/ai', aiRouter)
|
app.use('/api/ai', aiRouter)
|
||||||
app.use('/api/feedback', feedbackRouter)
|
app.use('/api/feedback', feedbackRouter)
|
||||||
|
app.use('/api/admin', adminRouter)
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express'
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
import { hasValidReauth, readSessionFromRequest } from '../session.js'
|
import { hasValidReauth, readSessionFromRequest } from '../session.js'
|
||||||
|
import { isAdminUserId } from '../adminConfig.js'
|
||||||
|
|
||||||
export interface AuthedRequest extends Request {
|
export interface AuthedRequest extends Request {
|
||||||
userId: string
|
userId: string
|
||||||
@@ -31,3 +32,21 @@ export function requireReauth(req: Request, res: Response, next: NextFunction):
|
|||||||
;(req as AuthedRequest).session = session
|
;(req as AuthedRequest).session = session
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const session = readSessionFromRequest(req)
|
||||||
|
if (!session) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized: valid session required' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdminUserId(session.userId)) {
|
||||||
|
res.status(403).json({ error: 'Forbidden: admin access required' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
;(req as AuthedRequest).userId = session.userId
|
||||||
|
;(req as AuthedRequest).session = session
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
import { requireUser, requireAdmin, type AuthedRequest } from '../middleware/auth.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/me', requireUser, requireAdmin, (req, res) => {
|
||||||
|
const { userId } = req as AuthedRequest
|
||||||
|
res.json({ isAdmin: true, userId })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const [totalUsers, totalLogbooks, totalPhotos, totalVoiceMemos, totalGpsTracks, totalCollaborations, totalInvitations, aiSummaryEntries] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.logbook.count(),
|
||||||
|
prisma.photoPayload.count(),
|
||||||
|
prisma.voiceMemoPayload.count(),
|
||||||
|
prisma.gpsTrackPayload.count(),
|
||||||
|
prisma.collaboration.count(),
|
||||||
|
prisma.invitation.count(),
|
||||||
|
prisma.aiSummaryUsage.count()
|
||||||
|
])
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalUsers,
|
||||||
|
totalLogbooks,
|
||||||
|
totalPhotos,
|
||||||
|
totalVoiceMemos,
|
||||||
|
totalGpsTracks,
|
||||||
|
totalCollaborations,
|
||||||
|
totalInvitations,
|
||||||
|
aiSummaryEntries
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('admin/summary error', error)
|
||||||
|
res.status(500).json({ error: 'Failed to load admin summary' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
type TimeBucket = 'day' | 'week' | 'month'
|
||||||
|
|
||||||
|
interface TimeSeriesPoint {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeSeries {
|
||||||
|
metric: string
|
||||||
|
points: TimeSeriesPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBucket(value: string | undefined | null): TimeBucket {
|
||||||
|
if (value === 'week' || value === 'month') return value
|
||||||
|
return 'day'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWindowDays(raw: string | undefined | null): number {
|
||||||
|
const n = raw ? Number.parseInt(raw, 10) : NaN
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 90
|
||||||
|
return Math.min(n, 365)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date): Date {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setUTCHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfWeek(date: Date): Date {
|
||||||
|
const d = startOfDay(date)
|
||||||
|
const day = d.getUTCDay() || 7
|
||||||
|
d.setUTCDate(d.getUTCDate() - (day - 1))
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfMonth(date: Date): Date {
|
||||||
|
const d = startOfDay(date)
|
||||||
|
d.setUTCDate(1)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketDate(date: Date, bucket: TimeBucket): string {
|
||||||
|
const base =
|
||||||
|
bucket === 'week' ? startOfWeek(date) : bucket === 'month' ? startOfMonth(date) : startOfDay(date)
|
||||||
|
return base.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<TimeSeries[]> {
|
||||||
|
const since = new Date()
|
||||||
|
since.setUTCDate(since.getUTCDate() - windowDays)
|
||||||
|
|
||||||
|
const [users, logbooks, photos] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { createdAt: { gte: since } },
|
||||||
|
select: { createdAt: true }
|
||||||
|
}),
|
||||||
|
prisma.logbook.findMany({
|
||||||
|
where: { createdAt: { gte: since } },
|
||||||
|
select: { createdAt: true }
|
||||||
|
}),
|
||||||
|
prisma.photoPayload.findMany({
|
||||||
|
where: { updatedAt: { gte: since } },
|
||||||
|
select: { updatedAt: true }
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
function aggregate(dates: Date[], metric: string): TimeSeries {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (const d of dates) {
|
||||||
|
const key = bucketDate(d, bucket)
|
||||||
|
map.set(key, (map.get(key) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const points = Array.from(map.entries())
|
||||||
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||||
|
.map(([date, count]) => ({ date, count }))
|
||||||
|
return { metric, points }
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
aggregate(
|
||||||
|
users.map((u) => u.createdAt),
|
||||||
|
'users_created'
|
||||||
|
),
|
||||||
|
aggregate(
|
||||||
|
logbooks.map((l) => l.createdAt),
|
||||||
|
'logbooks_created'
|
||||||
|
),
|
||||||
|
aggregate(
|
||||||
|
photos.map((p) => p.updatedAt),
|
||||||
|
'photos_updated'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/timeseries', requireUser, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bucket = normalizeBucket(typeof req.query.bucket === 'string' ? req.query.bucket : undefined)
|
||||||
|
const windowDays = parseWindowDays(typeof req.query.window === 'string' ? req.query.window : undefined)
|
||||||
|
|
||||||
|
const series = await buildTimeSeries(bucket, windowDays)
|
||||||
|
res.json({ bucket, windowDays, series })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('admin/timeseries error', error)
|
||||||
|
res.status(500).json({ error: 'Failed to load admin time series' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
Reference in New Issue
Block a user