Compare commits
11 Commits
v0.1.1.24
...
944f4518e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 944f4518e9 | |||
| 0c765f712c | |||
| 676547686b | |||
| 66606c5eca | |||
| a30fac029d | |||
| 796e61f4ea | |||
| 594c65d1a5 | |||
| fafefff29b | |||
| 4fd7f3c6cf | |||
| 262c48a01a | |||
| 9ad3c2cf38 |
@@ -34,6 +34,8 @@ ORIGIN=http://localhost:5173
|
||||
# POSTGRES_USER=postgres
|
||||
# POSTGRES_PASSWORD=
|
||||
# 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)
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
|
||||
@@ -7,12 +7,22 @@ import {
|
||||
type AdminTimeSeriesResponse,
|
||||
type AdminTimeBucket
|
||||
} 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 {
|
||||
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({
|
||||
icon,
|
||||
label,
|
||||
@@ -20,14 +30,14 @@ function KpiCard({
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: number
|
||||
value: number | string
|
||||
}) {
|
||||
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>
|
||||
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
|
||||
label="Einträge mit AI-Zusammenfassung"
|
||||
value={summary.aiSummaryEntries}
|
||||
/>
|
||||
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
|
||||
</section>
|
||||
|
||||
<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 Logbücher" seriesKey="logbooks_created" data={timeSeries} />
|
||||
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
|
||||
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
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 { loadPersonPool } from '../services/personPool.js'
|
||||
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
||||
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
|
||||
preloadedPool
|
||||
}: EntryCrewSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
|
||||
|
||||
return (
|
||||
<div className="form-card" data-tour="entry-crew">
|
||||
<div className="form-header">
|
||||
<Users size={22} className="form-icon" />
|
||||
<h3>{t('entry_crew.title')}</h3>
|
||||
</div>
|
||||
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
||||
|
||||
<div className="input-group mb-3">
|
||||
<label>{t('entry_crew.day_skipper')}</label>
|
||||
{skippers.length === 0 ? (
|
||||
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||
<div
|
||||
className="form-header accordion-header"
|
||||
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">
|
||||
<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">
|
||||
{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>
|
||||
<ChevronUp size={20} className="accordion-chevron" />
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
|
||||
|
||||
<div className="input-group mb-3">
|
||||
<label>{t('entry_crew.day_skipper')}</label>
|
||||
{skippers.length === 0 ? (
|
||||
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
|
||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
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'
|
||||
|
||||
interface PhotoCaptureProps {
|
||||
@@ -29,6 +29,7 @@ interface DecryptedPhoto {
|
||||
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [caption, setCaption] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -165,133 +166,156 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
|
||||
return (
|
||||
<div className="form-card mt-6">
|
||||
<div className="form-header mb-4">
|
||||
<Camera size={20} className="form-icon" />
|
||||
<h3>{t('logs.photos_title')}</h3>
|
||||
<div
|
||||
className="form-header accordion-header"
|
||||
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>
|
||||
|
||||
{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 Form */}
|
||||
{!readOnly && (
|
||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
{/* Upload area */}
|
||||
{/* Upload Form */}
|
||||
{!readOnly && (
|
||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{hasCamera ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerCameraSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={triggerGallerySelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Image size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
||||
</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>
|
||||
{hasCamera ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerCameraSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={triggerGallerySelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Image size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<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
|
||||
type="button"
|
||||
className="photo-btn-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(photo.payloadId)
|
||||
}}
|
||||
title="Remove photo"
|
||||
className="btn primary"
|
||||
onClick={triggerGallerySelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{photo.caption && (
|
||||
<div className="photo-caption-bar">
|
||||
<span>{photo.caption}</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AdminSummary {
|
||||
totalCollaborations: number
|
||||
totalInvitations: number
|
||||
aiSummaryEntries: number
|
||||
dbSize: number
|
||||
}
|
||||
|
||||
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
||||
|
||||
+30
-17
@@ -70,6 +70,11 @@ DEFAULT_VERSION="0.1.0.0"
|
||||
MAX_WAIT=90
|
||||
|
||||
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
|
||||
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
|
||||
@@ -186,34 +191,34 @@ ensure_local_sync_with_origin() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Syncing with origin..."
|
||||
git fetch --tags origin
|
||||
echo "Syncing with ${GIT_REMOTE}..."
|
||||
git fetch --tags "${GIT_REMOTE}"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: git fetch origin failed." >&2
|
||||
echo "Error: git fetch ${GIT_REMOTE} failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
|
||||
echo "Error: origin/${branch} does not exist." >&2
|
||||
if ! git rev-parse --verify "${GIT_REMOTE}/${branch}" >/dev/null 2>&1; then
|
||||
echo "Error: ${GIT_REMOTE}/${branch} does not exist." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
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
|
||||
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 " 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
|
||||
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
|
||||
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
|
||||
elif git merge-base --is-ancestor "${GIT_REMOTE}/${branch}" "$local_sha" 2>/dev/null; then
|
||||
echo "Hint: run 'git push ${GIT_REMOTE} ${branch}' before deploying." >&2
|
||||
else
|
||||
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
|
||||
fi
|
||||
@@ -246,12 +251,12 @@ prepare_release() {
|
||||
echo " Next prep: v${next_version}"
|
||||
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
|
||||
current_branch="$(git branch --show-current)"
|
||||
git push origin "$current_branch"
|
||||
git push origin "$tag_name"
|
||||
echo "Pushed ${current_branch} and ${tag_name} to origin."
|
||||
git push "${GIT_REMOTE}" "$current_branch"
|
||||
git push "${GIT_REMOTE}" "$tag_name"
|
||||
echo "Pushed ${current_branch} and ${tag_name} to ${GIT_REMOTE}."
|
||||
else
|
||||
echo "Skipped push. Remote host must receive this commit/tag manually."
|
||||
fi
|
||||
@@ -281,7 +286,7 @@ echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
echo "=================================================="
|
||||
|
||||
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
|
||||
|
||||
REMOTE_DIR="$1"
|
||||
@@ -292,9 +297,17 @@ APP_URL="$5"
|
||||
APP_VERSION="$6"
|
||||
DEST="$7"
|
||||
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; }
|
||||
|
||||
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
|
||||
echo "Warning: Local changes on deployment host will be discarded."
|
||||
fi
|
||||
|
||||
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
|
||||
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({
|
||||
totalUsers,
|
||||
totalLogbooks,
|
||||
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
|
||||
totalGpsTracks,
|
||||
totalCollaborations,
|
||||
totalInvitations,
|
||||
aiSummaryEntries
|
||||
aiSummaryEntries,
|
||||
dbSize
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error('admin/summary error', error)
|
||||
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
||||
const since = new Date()
|
||||
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({
|
||||
where: { createdAt: { gte: since } },
|
||||
select: { createdAt: true }
|
||||
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
||||
prisma.photoPayload.findMany({
|
||||
where: { updatedAt: { gte: since } },
|
||||
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 {
|
||||
const map = new Map<string, number>()
|
||||
for (const d of dates) {
|
||||
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
||||
aggregate(
|
||||
photos.map((p) => p.updatedAt),
|
||||
'photos_updated'
|
||||
)
|
||||
),
|
||||
{
|
||||
metric: 'database_size',
|
||||
points: dbSizePoints
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user