Compare commits

...

11 Commits

8 changed files with 332 additions and 182 deletions
+2
View File
@@ -34,6 +34,8 @@ ORIGIN=http://localhost:5173
# POSTGRES_USER=postgres # POSTGRES_USER=postgres
# POSTGRES_PASSWORD= # POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox # POSTGRES_DB=daagbox
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
# COMPOSE_FILE=docker-compose.staging.yml
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login) # Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173 # CORS_ORIGINS=http://localhost:5173
+1 -1
View File
@@ -1 +1 @@
0.1.1.25 0.1.1.28
+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>
+69 -44
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react' import { Users, ChevronDown, ChevronUp } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js' import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js' import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js' import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
preloadedPool preloadedPool
}: EntryCrewSectionProps) { }: EntryCrewSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map()) const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => { useEffect(() => {
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
return ( return (
<div className="form-card" data-tour="entry-crew"> <div className="form-card" data-tour="entry-crew">
<div className="form-header"> <div
<Users size={22} className="form-icon" /> className="form-header accordion-header"
<h3>{t('entry_crew.title')}</h3> onClick={() => setCollapsed(!collapsed)}
</div> onKeyDown={(e) => {
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p> if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
<div className="input-group mb-3"> setCollapsed(!collapsed)
<label>{t('entry_crew.day_skipper')}</label> }
{skippers.length === 0 ? ( }}
<p className="help-text">{t('entry_crew.no_skipper')}</p> role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : ( ) : (
<div className="crew-selection-list"> <ChevronUp size={20} className="accordion-chevron" />
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)} )}
</div> </div>
<div className="input-group"> {!collapsed && (
<label>{t('entry_crew.day_crew')}</label> <>
{crewEntries.length === 0 ? ( <p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : ( <div className="input-group mb-3">
<div className="crew-selection-list"> <label>{t('entry_crew.day_skipper')}</label>
{crewEntries.map(([id, data]) => ( {skippers.length === 0 ? (
<label key={id} className="crew-selection-item"> <p className="help-text">{t('entry_crew.no_skipper')}</p>
<input ) : (
type="checkbox" <div className="crew-selection-list">
checked={value.selectedCrewIds.includes(id)} {skippers.map(([id, data]) => (
onChange={() => toggleCrew(id)} <label key={id} className="crew-selection-item">
disabled={readOnly} <input
/> type="radio"
<span>{data.name || t('logbook_crew.unnamed')}</span> name={`entry-skipper-${logbookId}`}
</label> checked={value.selectedSkipperId === id}
))} onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div> </div>
)}
</div> <div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</>
)}
</div> </div>
) )
} }
+138 -114
View File
@@ -9,7 +9,7 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Camera, Image, Trash2, X } from 'lucide-react' import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js' import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps { interface PhotoCaptureProps {
@@ -29,6 +29,7 @@ interface DecryptedPhoto {
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) { export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const [collapsed, setCollapsed] = useState(true)
const [caption, setCaption] = useState('') const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -165,133 +166,156 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
return ( return (
<div className="form-card mt-6"> <div className="form-card mt-6">
<div className="form-header mb-4"> <div
<Camera size={20} className="form-icon" /> className="form-header accordion-header"
<h3>{t('logs.photos_title')}</h3> onClick={() => setCollapsed(!collapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setCollapsed(!collapsed)
}
}}
role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Camera size={20} className="form-icon" />
<h3>{t('logs.photos_title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : (
<ChevronUp size={20} className="accordion-chevron" />
)}
</div> </div>
{error && <div className="auth-error mb-4">{error}</div>} {!collapsed && (
<div style={{ marginTop: '16px' }}>
{error && <div className="auth-error mb-4">{error}</div>}
{/* Upload area */} {/* Upload area */}
{/* Upload Form */} {/* Upload Form */}
{!readOnly && ( {!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}> <div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}> <div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label> <label>{t('logs.photo_caption_label')}</label>
<input <input
type="text" type="text"
placeholder={t('logs.photo_caption_placeholder')} placeholder={t('logs.photo_caption_placeholder')}
className="input-text" className="input-text"
value={caption} value={caption}
onChange={(e) => setCaption(e.target.value)} onChange={(e) => setCaption(e.target.value)}
disabled={uploading} disabled={uploading}
/> />
</div> </div>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment" capture="environment"
ref={cameraInputRef} ref={cameraInputRef}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
{hasCamera ? ( {hasCamera ? (
<> <>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
onClick={triggerCameraSelect} onClick={triggerCameraSelect}
disabled={uploading} disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }} style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
> >
{uploading ? ( {uploading ? (
<span className="spin"></span> <span className="spin"></span>
) : ( ) : (
<Camera size={16} /> <Camera size={16} />
)} )}
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')} {uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
</button> </button>
<button <button
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={triggerGallerySelect} onClick={triggerGallerySelect}
disabled={uploading} disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }} style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
> >
{uploading ? ( {uploading ? (
<span className="spin"></span> <span className="spin"></span>
) : ( ) : (
<Image size={16} /> <Image size={16} />
)} )}
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')} {uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
</button> </button>
</> </>
) : (
<button
type="button"
className="btn primary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : ( ) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
)}
</div>
</div>
)}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div
key={photo.payloadId}
className="photo-card glass"
onClick={() => setMaximizedPhoto(photo)}
style={{ cursor: 'pointer' }}
>
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
<button <button
type="button" type="button"
className="photo-btn-delete" className="btn primary"
onClick={(e) => { onClick={triggerGallerySelect}
e.stopPropagation() disabled={uploading}
handleDelete(photo.payloadId) style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
}}
title="Remove photo"
> >
<Trash2 size={16} /> {uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button> </button>
)} )}
</div> </div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div> </div>
))} )}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div
key={photo.payloadId}
className="photo-card glass"
onClick={() => setMaximizedPhoto(photo)}
style={{ cursor: 'pointer' }}
>
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
<button
type="button"
className="photo-btn-delete"
onClick={(e) => {
e.stopPropagation()
handleDelete(photo.payloadId)
}}
title="Remove photo"
>
<Trash2 size={16} />
</button>
)}
</div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div>
))}
</div>
)}
</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'
+30 -17
View File
@@ -70,6 +70,11 @@ DEFAULT_VERSION="0.1.0.0"
MAX_WAIT=90 MAX_WAIT=90
REMOTE_USER="${REMOTE_USER:-root}" REMOTE_USER="${REMOTE_USER:-root}"
# GIT_REMOTE="${GIT_REMOTE:-github}"
# GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://github.com/elpatron68/kapteins-daagbok.git}"
GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://gitea.elpatron.me/elpatron/kapteins-daagbok.git}"
if [[ "$DEST" == "stage" ]]; then if [[ "$DEST" == "stage" ]]; then
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}" REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
@@ -186,34 +191,34 @@ ensure_local_sync_with_origin() {
exit 1 exit 1
fi fi
echo "Syncing with origin..." echo "Syncing with ${GIT_REMOTE}..."
git fetch --tags origin git fetch --tags "${GIT_REMOTE}"
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "Error: git fetch origin failed." >&2 echo "Error: git fetch ${GIT_REMOTE} failed." >&2
exit 1 exit 1
fi fi
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then if ! git rev-parse --verify "${GIT_REMOTE}/${branch}" >/dev/null 2>&1; then
echo "Error: origin/${branch} does not exist." >&2 echo "Error: ${GIT_REMOTE}/${branch} does not exist." >&2
exit 1 exit 1
fi fi
local_sha="$(git rev-parse HEAD)" local_sha="$(git rev-parse HEAD)"
origin_sha="$(git rev-parse "origin/${branch}")" origin_sha="$(git rev-parse "${GIT_REMOTE}/${branch}")"
if [ "$local_sha" = "$origin_sha" ]; then if [ "$local_sha" = "$origin_sha" ]; then
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))." echo "Local branch '$branch' matches ${GIT_REMOTE}/${branch} ($(git rev-parse --short HEAD))."
return 0 return 0
fi fi
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2 echo "Error: Local '$branch' is not in sync with ${GIT_REMOTE}/${branch}." >&2
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2 echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2 echo " ${GIT_REMOTE}: $(git rev-parse --short "${GIT_REMOTE}/${branch}") $(git log -1 --format='%s' "${GIT_REMOTE}/${branch}")" >&2
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then if git merge-base --is-ancestor "$local_sha" "${GIT_REMOTE}/${branch}" 2>/dev/null; then
echo "Hint: run 'git pull' to fast-forward." >&2 echo "Hint: run 'git pull' to fast-forward." >&2
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then elif git merge-base --is-ancestor "${GIT_REMOTE}/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push origin ${branch}' before deploying." >&2 echo "Hint: run 'git push ${GIT_REMOTE} ${branch}' before deploying." >&2
else else
echo "Hint: branches have diverged — reconcile manually before deploying." >&2 echo "Hint: branches have diverged — reconcile manually before deploying." >&2
fi fi
@@ -246,12 +251,12 @@ prepare_release() {
echo " Next prep: v${next_version}" echo " Next prep: v${next_version}"
echo "" echo ""
read -r -p "Push commit and tag to origin? [Y/n] " push_answer read -r -p "Push commit and tag to ${GIT_REMOTE}? [Y/n] " push_answer
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
current_branch="$(git branch --show-current)" current_branch="$(git branch --show-current)"
git push origin "$current_branch" git push "${GIT_REMOTE}" "$current_branch"
git push origin "$tag_name" git push "${GIT_REMOTE}" "$tag_name"
echo "Pushed ${current_branch} and ${tag_name} to origin." echo "Pushed ${current_branch} and ${tag_name} to ${GIT_REMOTE}."
else else
echo "Skipped push. Remote host must receive this commit/tag manually." echo "Skipped push. Remote host must receive this commit/tag manually."
fi fi
@@ -281,7 +286,7 @@ echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "==================================================" echo "=================================================="
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \ ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT' "$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" "$GIT_REMOTE_URL" <<'REMOTE_SCRIPT'
set -uo pipefail set -uo pipefail
REMOTE_DIR="$1" REMOTE_DIR="$1"
@@ -292,9 +297,17 @@ APP_URL="$5"
APP_VERSION="$6" APP_VERSION="$6"
DEST="$7" DEST="$7"
DEPLOY_BRANCH="${8:-}" DEPLOY_BRANCH="${8:-}"
GIT_REMOTE_URL="${9:-https://github.com/elpatron68/kapteins-daagbok.git}"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; } cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
echo "Configuring git remote 'origin' URL to ${GIT_REMOTE_URL} on remote host..."
if git remote | grep -q "^origin$"; then
git remote set-url origin "$GIT_REMOTE_URL"
else
git remote add origin "$GIT_REMOTE_URL"
fi
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded." echo "Warning: Local changes on deployment host will be discarded."
fi fi
+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
}
] ]
} }