From e5dc347a0b9bc1caa3c5a07f2de688c7ef904d3c Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Tue, 2 Jun 2026 17:44:46 +0300 Subject: [PATCH] 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 --- Dockerfile | 2 + package-lock.json | 18 ++++ package.json | 2 + src/app/api/analytics/stats/route.ts | 35 +++++- src/app/api/backups/route.ts | 73 +++++++++++++ src/app/api/db/backup/route.ts | 47 ++++++++ src/app/dashboard/analytics/page.tsx | 120 ++++++++++++++++++++- src/app/dashboard/databases/page.tsx | 114 +++++++++++++++++++- src/instrumentation.ts | 4 + src/lib/appDb.ts | 50 ++++++++- src/lib/backup.ts | 153 +++++++++++++++++++++++++++ src/lib/cronWorker.ts | 78 ++++++++++++++ 12 files changed, 686 insertions(+), 10 deletions(-) create mode 100644 src/app/api/backups/route.ts create mode 100644 src/app/api/db/backup/route.ts create mode 100644 src/lib/backup.ts create mode 100644 src/lib/cronWorker.ts diff --git a/Dockerfile b/Dockerfile index fa995c2..3804255 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,8 @@ RUN npm run build FROM base AS runner WORKDIR /app +RUN apk add --no-cache postgresql-client + ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/package-lock.json b/package-lock.json index a03ea29..ce00e5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "jose": "^6.2.3", "lucide-react": "^1.16.0", "next": "16.2.6", + "node-cron": "^4.2.1", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4" @@ -20,6 +21,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/node-cron": "^3.0.11", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -1585,6 +1587,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -5143,6 +5152,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", diff --git a/package.json b/package.json index 0bc8267..34d41f5 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "jose": "^6.2.3", "lucide-react": "^1.16.0", "next": "16.2.6", + "node-cron": "^4.2.1", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4" @@ -21,6 +22,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/node-cron": "^3.0.11", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/src/app/api/analytics/stats/route.ts b/src/app/api/analytics/stats/route.ts index aa566ff..5f67f83 100644 --- a/src/app/api/analytics/stats/route.ts +++ b/src/app/api/analytics/stats/route.ts @@ -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 }) +} diff --git a/src/app/api/backups/route.ts b/src/app/api/backups/route.ts new file mode 100644 index 0000000..63bd444 --- /dev/null +++ b/src/app/api/backups/route.ts @@ -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 }) + } +} diff --git a/src/app/api/db/backup/route.ts b/src/app/api/db/backup/route.ts new file mode 100644 index 0000000..7c37b2d --- /dev/null +++ b/src/app/api/db/backup/route.ts @@ -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 }) + } +} diff --git a/src/app/dashboard/analytics/page.tsx b/src/app/dashboard/analytics/page.tsx index 3144189..fdf229b 100644 --- a/src/app/dashboard/analytics/page.tsx +++ b/src/app/dashboard/analytics/page.tsx @@ -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(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 = ` ` @@ -103,6 +145,16 @@ export default function AnalyticsPage() {

Cookie-free, self-hosted — Plausible benzeri

+ {selectedDomain && ( + <> + + + + )} +
)} + + {/* Add Site modal */} + {showAddSite && ( +
setShowAddSite(false)}> +
e.stopPropagation()}> +
Yeni Site Ekle
+
+ Takip etmek istediğiniz alan adını girin (örn: example.com) +
+ + 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()); + }) + } + }} + /> + +
+ + +
+
+
+ )} ) } + diff --git a/src/app/dashboard/databases/page.tsx b/src/app/dashboard/databases/page.tsx index 2950274..78e3d7d 100644 --- a/src/app/dashboard/databases/page.tsx +++ b/src/app/dashboard/databases/page.tsx @@ -8,7 +8,7 @@ type QueryResult = { rows: Record[]; fields: string[]; error: s export default function DatabasesPage() { const [dbs, setDbs] = useState([]) const [selected, setSelected] = useState(null) - const [tab, setTab] = useState<'stats' | 'tables' | 'query'>('tables') + const [tab, setTab] = useState<'stats' | 'tables' | 'query' | 'backups'>('tables') const [stats, setStats] = useState | null>(null) const [tables, setTables] = useState([]) const [sql, setSql] = useState('SELECT * FROM ') @@ -107,9 +107,9 @@ export default function DatabasesPage() { {/* Tabs */}
- {(['tables', 'query', 'stats'] as const).map(t => ( + {(['tables', 'query', 'stats', 'backups'] as const).map(t => ( ))}
@@ -178,6 +178,9 @@ export default function DatabasesPage() {
{JSON.stringify(stats, null, 2)}
)} + + {/* Backups */} + {tab === 'backups' && } )} @@ -273,3 +276,108 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => ) } + +function BackupTab({ db }: { db: Db }) { + const [config, setConfig] = useState(null) + const [logs, setLogs] = useState([]) + 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
Yükleniyor...
+ + 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 ( +
+
+
+

Otomatik Yedekleme

+ + ↓ Hemen İndir + +
+ +
setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" />
+ +
+ + +
+ +
+ +