feat: database backups & analytics site management

- Added manual database backup functionality with pg_dump
- Added cron-based automated backups to Google Drive and Dropbox
- Added ability to manually add, edit, and delete sites in Analytics
- Added 1-day timeframe filter in Analytics page
- Updated Dockerfile to include postgresql-client
This commit is contained in:
mstfyldz
2026-06-02 17:44:46 +03:00
parent e28fa966e3
commit e5dc347a0b
12 changed files with 686 additions and 10 deletions
+34 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getAnalyticsStats, getAllDomains } from '@/lib/appDb'
import { getAnalyticsStats, getAllDomains, addAnalyticsSite, renameAnalyticsSite, deleteAnalyticsSite } from '@/lib/appDb'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
@@ -18,3 +18,36 @@ export async function GET(req: NextRequest) {
const stats = await getAnalyticsStats(domain, days)
return NextResponse.json(stats)
}
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { domain } = await req.json()
if (!domain) return NextResponse.json({ error: 'Domain required' }, { status: 400 })
await addAnalyticsSite(domain)
return NextResponse.json({ ok: true })
}
export async function PUT(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { oldDomain, newDomain } = await req.json()
if (!oldDomain || !newDomain) return NextResponse.json({ error: 'Missing params' }, { status: 400 })
await renameAnalyticsSite(oldDomain, newDomain)
return NextResponse.json({ ok: true })
}
export async function DELETE(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const domain = req.nextUrl.searchParams.get('domain')
if (!domain) return NextResponse.json({ error: 'Domain required' }, { status: 400 })
await deleteAnalyticsSite(domain)
return NextResponse.json({ ok: true })
}
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/appDb'
import { generateId } from '@/lib/config'
import { reloadBackupCrons } from '@/lib/cronWorker'
// GET configs and logs for a dbId
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const dbId = searchParams.get('dbId')
if (!dbId) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
try {
const configRes = await pool.query(`SELECT * FROM backup_configs WHERE db_id = $1 LIMIT 1`, [dbId])
const logsRes = await pool.query(`SELECT * FROM backup_logs WHERE db_id = $1 ORDER BY ts DESC LIMIT 50`, [dbId])
return NextResponse.json({
config: configRes.rows[0] || null,
logs: logsRes.rows.map(r => ({ ...r, file_size: Number(r.file_size), ts: Number(r.ts) }))
})
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
// Create or update a backup config
export async function POST(req: Request) {
try {
const body = await req.json()
const { db_id, schedule, cloud_type, credentials } = body
if (!db_id || !schedule || !cloud_type || !credentials) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
}
// Check if exists
const existing = await pool.query(`SELECT id FROM backup_configs WHERE db_id = $1`, [db_id])
if (existing.rows.length > 0) {
await pool.query(
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3 WHERE db_id=$4`,
[schedule, cloud_type, credentials, db_id]
)
} else {
await pool.query(
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials) VALUES ($1, $2, $3, $4, $5)`,
[generateId(), db_id, schedule, cloud_type, credentials]
)
}
// Reload crons in memory
await reloadBackupCrons()
return NextResponse.json({ ok: true })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
// Delete backup config
export async function DELETE(req: Request) {
try {
const body = await req.json()
const { db_id } = body
if (!db_id) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
await pool.query(`DELETE FROM backup_configs WHERE db_id = $1`, [db_id])
await reloadBackupCrons()
return NextResponse.json({ ok: true })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import { readConfig } from '@/lib/config'
import { createPgDumpStream } from '@/lib/backup'
// GET /api/db/backup?dbId=...
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const dbId = searchParams.get('dbId')
if (!dbId) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
const config = await readConfig()
const db = config.databases.find(d => d.id === dbId)
if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
try {
const child = await createPgDumpStream(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql`
// Stream the output of pg_dump to the response
const stream = new ReadableStream({
start(controller) {
child.stdout.on('data', (chunk) => controller.enqueue(chunk))
child.on('close', (code) => {
if (code === 0) {
controller.close()
} else {
controller.error(new Error(`pg_dump exited with code ${code}`))
}
})
child.on('error', (err) => controller.error(err))
},
cancel() {
child.kill()
}
})
return new NextResponse(stream, {
headers: {
'Content-Type': 'application/sql',
'Content-Disposition': `attachment; filename="${filename}"`
}
})
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+115 -5
View File
@@ -14,6 +14,7 @@ type Stats = {
}
const DAYS_OPTIONS = [
{ label: '1g', value: 1 },
{ label: '7g', value: 7 },
{ label: '30g', value: 30 },
{ label: '90g', value: 90 },
@@ -69,17 +70,30 @@ export default function AnalyticsPage() {
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(false)
const [showSnippet, setShowSnippet] = useState(false)
const [showAddSite, setShowAddSite] = useState(false)
const [newDomain, setNewDomain] = useState('')
const [panelUrl, setPanelUrl] = useState('')
useEffect(() => {
setPanelUrl(window.location.origin)
const loadDomains = useCallback(() => {
fetch('/api/analytics/stats')
.then(r => r.json())
.then(d => {
setDomains(d.domains ?? [])
if (d.domains?.length > 0) setSelectedDomain(d.domains[0])
const dms = d.domains ?? []
setDomains(dms)
if (dms.length > 0) {
if (!selectedDomain || !dms.includes(selectedDomain)) {
setSelectedDomain(dms[0])
}
} else {
setSelectedDomain(null)
}
})
}, [])
}, [selectedDomain])
useEffect(() => {
setPanelUrl(window.location.origin)
loadDomains()
}, [loadDomains])
const loadStats = useCallback(async () => {
if (!selectedDomain) return
@@ -91,6 +105,34 @@ export default function AnalyticsPage() {
useEffect(() => { loadStats() }, [loadStats])
const handleRename = async () => {
if (!selectedDomain) return
const newName = window.prompt("Yeni alan adını girin (Örn: example.com):", selectedDomain)
if (!newName || newName.trim() === '' || newName === selectedDomain) return
setLoading(true)
await fetch('/api/analytics/stats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldDomain: selectedDomain, newDomain: newName.trim() })
})
setSelectedDomain(newName.trim())
// Refresh domains after brief delay for state to sync
setTimeout(loadDomains, 100)
}
const handleDelete = async () => {
if (!selectedDomain) return
if (!window.confirm(`'${selectedDomain}' sitesini ve tüm analiz verilerini SİLMEK istediğinize emin misiniz? Bu işlem geri alınamaz!`)) return
setLoading(true)
await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}`, { method: 'DELETE' })
setSelectedDomain(null)
setTimeout(loadDomains, 100)
}
const snippet = `<!-- VPS Panel Analytics -->
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
@@ -103,6 +145,16 @@ export default function AnalyticsPage() {
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cookie-free, self-hosted Plausible benzeri</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{selectedDomain && (
<>
<button onClick={handleRename} style={{ background: 'transparent', border: '1px solid var(--border)', borderRadius: 7, padding: '8px 14px', color: 'var(--text)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Düzenle</button>
<button onClick={handleDelete} style={{ background: 'rgba(255,50,50,.1)', border: '1px solid rgba(255,50,50,.3)', borderRadius: 7, padding: '8px 14px', color: 'var(--red, #ff5c5c)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Sil</button>
</>
)}
<button onClick={() => setShowAddSite(true)}
style={{ background: 'rgba(0,229,255,.12)', border: '1px solid rgba(0,229,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
+ Site Ekle
</button>
<button onClick={() => setShowSnippet(true)}
style={{ background: 'rgba(123,97,255,.12)', border: '1px solid rgba(123,97,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--purple, #7b61ff)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
{'</>'} Snippet
@@ -257,6 +309,64 @@ export default function AnalyticsPage() {
</div>
</div>
)}
{/* Add Site modal */}
{showAddSite && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={() => setShowAddSite(false)}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 400, maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Yeni Site Ekle</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}>
Takip etmek istediğiniz alan adını girin (örn: example.com)
</div>
<input
style={{ width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 20 }}
placeholder="example.com"
value={newDomain}
onChange={e => setNewDomain(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newDomain.trim()) {
fetch('/api/analytics/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: newDomain.trim() })
}).then(() => {
setNewDomain('');
setShowAddSite(false);
loadDomains();
setSelectedDomain(newDomain.trim());
})
}
}}
/>
<div style={{ display: 'flex', gap: 10 }}>
<button
disabled={!newDomain.trim()}
onClick={() => {
fetch('/api/analytics/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: newDomain.trim() })
}).then(() => {
setNewDomain('');
setShowAddSite(false);
loadDomains();
setSelectedDomain(newDomain.trim());
})
}}
style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
Ekle
</button>
<button onClick={() => setShowAddSite(false)}
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
İptal
</button>
</div>
</div>
</div>
)}
</div>
)
}
+111 -3
View File
@@ -8,7 +8,7 @@ type QueryResult = { rows: Record<string, unknown>[]; fields: string[]; error: s
export default function DatabasesPage() {
const [dbs, setDbs] = useState<Db[]>([])
const [selected, setSelected] = useState<Db | null>(null)
const [tab, setTab] = useState<'stats' | 'tables' | 'query'>('tables')
const [tab, setTab] = useState<'stats' | 'tables' | 'query' | 'backups'>('tables')
const [stats, setStats] = useState<Record<string, unknown> | null>(null)
const [tables, setTables] = useState<Table[]>([])
const [sql, setSql] = useState('SELECT * FROM ')
@@ -107,9 +107,9 @@ export default function DatabasesPage() {
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 16, gap: 4 }}>
{(['tables', 'query', 'stats'] as const).map(t => (
{(['tables', 'query', 'stats', 'backups'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{ padding: '8px 16px', fontSize: 13, fontWeight: 600, color: tab === t ? 'var(--accent)' : 'var(--muted)', background: 'none', border: 'none', borderBottomWidth: 2, borderBottomStyle: 'solid', borderBottomColor: tab === t ? 'var(--accent)' : 'transparent', cursor: 'pointer', marginBottom: -1 }}>
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : 'İstatistik'}
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : t === 'stats' ? 'İstatistik' : 'Yedekler'}
</button>
))}
</div>
@@ -178,6 +178,9 @@ export default function DatabasesPage() {
<pre style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--muted)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(stats, null, 2)}</pre>
</div>
)}
{/* Backups */}
{tab === 'backups' && <BackupTab db={selected} />}
</div>
)}
</div>
@@ -273,3 +276,108 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
</div>
)
}
function BackupTab({ db }: { db: Db }) {
const [config, setConfig] = useState<any>(null)
const [logs, setLogs] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
const load = useCallback(async () => {
setLoading(true)
const r = await fetch(`/api/backups?dbId=${db.id}`)
if (r.ok) {
const data = await r.json()
setConfig(data.config)
setLogs(data.logs)
if (data.config) {
setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials })
}
}
setLoading(false)
}, [db.id])
useEffect(() => { load() }, [load])
const saveConfig = async () => {
setSaving(true)
await fetch('/api/backups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id, ...f })
})
await load()
setSaving(false)
}
const deleteConfig = async () => {
if (!confirm('Otomatik yedeklemeyi kapat?')) return
await fetch('/api/backups', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id })
})
setConfig(null)
setF({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
}
if (loading) return <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Yükleniyor...</div>
const inp = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 12 }
const lbl = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 } as any
return (
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
<div style={{ flex: 1, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 700 }}>Otomatik Yedekleme</h3>
<a href={`/api/db/backup?dbId=${db.id}`} download style={{ background: 'rgba(255,255,255,.1)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 12px', color: 'var(--text)', fontSize: 12, textDecoration: 'none' }}>
Hemen İndir
</a>
</div>
<div><label style={lbl}>Cron Schedule (node-cron)</label><input style={inp} value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" /></div>
<div style={{ marginBottom: 12 }}>
<label style={lbl}>Cloud Sağlayıcı</label>
<select style={inp as any} value={f.cloud_type} onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}>
<option value="dropbox">Dropbox</option>
<option value="gdrive">Google Drive</option>
</select>
</div>
<div>
<label style={lbl}>Credentials (JSON veya Token)</label>
<textarea style={{ ...inp, resize: 'vertical' } as any} rows={4} value={f.credentials} onChange={e => setF(p => ({ ...p, credentials: e.target.value }))} placeholder={f.cloud_type === 'dropbox' ? 'Dropbox Access Token buraya...' : 'Google Service Account JSON buraya...'} />
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={saveConfig} disabled={saving} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>{saving ? 'Kaydediliyor...' : 'Kaydet'}</button>
{config && (
<button onClick={deleteConfig} style={{ background: 'rgba(255,0,0,.1)', border: '1px solid rgba(255,0,0,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--red)', cursor: 'pointer', fontSize: 13 }}>Kapat</button>
)}
</div>
</div>
<div style={{ width: 340, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>Geçmiş İşlemler</h3>
{logs.length === 0 ? <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Kayıt yok</div> : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{logs.map((log: any) => (
<div key={log.id} style={{ padding: 12, background: 'rgba(0,0,0,.2)', borderRadius: 6, borderLeft: `3px solid ${log.status === 'success' ? '#00e676' : 'var(--red)'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: log.status === 'success' ? '#00e676' : 'var(--red)' }}>{log.status.toUpperCase()}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{new Date(log.ts * 1000).toLocaleString()}</span>
</div>
{log.status === 'success' && <div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Boyut: {(log.file_size / 1024).toFixed(2)} KB</div>}
{log.status !== 'success' && <div style={{ fontSize: 11, color: 'var(--red)', fontFamily: 'monospace', marginTop: 4 }}>{log.message}</div>}
</div>
))}
</div>
)}
</div>
</div>
)
}