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:
@@ -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
|
||||
|
||||
|
||||
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ export async function register() {
|
||||
// Dynamically import inside register
|
||||
const { readConfig } = await import('./lib/config')
|
||||
const { pingAndLog } = await import('./lib/ping')
|
||||
const { startBackupCron, reloadBackupCrons } = await import('./lib/cronWorker')
|
||||
|
||||
startBackupCron()
|
||||
await reloadBackupCrons()
|
||||
|
||||
// Bellek içi son ping zamanlarını tutacak obje
|
||||
const lastPingMap: Record<string, number> = {}
|
||||
|
||||
+49
-1
@@ -33,6 +33,12 @@ pool.query(`
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_domain ON pageviews(domain, ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_ts ON pageviews(ts DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_sites (
|
||||
domain TEXT PRIMARY KEY,
|
||||
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -59,6 +65,25 @@ pool.query(`
|
||||
icon TEXT,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
db_id TEXT NOT NULL,
|
||||
schedule TEXT NOT NULL,
|
||||
cloud_type TEXT NOT NULL,
|
||||
credentials TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
db_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
file_size BIGINT,
|
||||
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_db_ts ON backup_logs(db_id, ts DESC);
|
||||
`).catch(console.error)
|
||||
export type PingLog = {
|
||||
id: number
|
||||
@@ -192,8 +217,31 @@ export async function getAnalyticsStats(domain: string, days = 30) {
|
||||
}
|
||||
|
||||
export async function getAllDomains(): Promise<string[]> {
|
||||
const res = await pool.query(`SELECT DISTINCT domain FROM pageviews ORDER BY domain`)
|
||||
const res = await pool.query(`
|
||||
SELECT domain FROM analytics_sites
|
||||
UNION
|
||||
SELECT DISTINCT domain FROM pageviews
|
||||
ORDER BY domain
|
||||
`)
|
||||
return res.rows.map(r => r.domain)
|
||||
}
|
||||
|
||||
export async function addAnalyticsSite(domain: string) {
|
||||
await pool.query(
|
||||
`INSERT INTO analytics_sites (domain) VALUES ($1) ON CONFLICT DO NOTHING`,
|
||||
[domain]
|
||||
)
|
||||
}
|
||||
|
||||
export async function renameAnalyticsSite(oldDomain: string, newDomain: string) {
|
||||
// Update both tables
|
||||
await pool.query(`UPDATE analytics_sites SET domain = $1 WHERE domain = $2`, [newDomain, oldDomain])
|
||||
await pool.query(`UPDATE pageviews SET domain = $1 WHERE domain = $2`, [newDomain, oldDomain])
|
||||
}
|
||||
|
||||
export async function deleteAnalyticsSite(domain: string) {
|
||||
await pool.query(`DELETE FROM analytics_sites WHERE domain = $1`, [domain])
|
||||
await pool.query(`DELETE FROM pageviews WHERE domain = $1`, [domain])
|
||||
}
|
||||
|
||||
export default pool
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { SignJWT, importPKCS8 } from 'jose'
|
||||
|
||||
export interface Database {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
database: string
|
||||
username: string
|
||||
password: string
|
||||
ssl: boolean
|
||||
color?: string
|
||||
}
|
||||
|
||||
export async function createPgDumpStream(db: Database) {
|
||||
const env = {
|
||||
...process.env,
|
||||
PGPASSWORD: db.password,
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-h', db.host,
|
||||
'-p', db.port.toString(),
|
||||
'-U', db.username,
|
||||
'-d', db.database,
|
||||
'-F', 'c', // custom format for smaller size / better restore, or 'p' for plain sql. Let's use 'p' for plain text so they can see it.
|
||||
'--no-owner',
|
||||
]
|
||||
// if we want plain text:
|
||||
args[args.indexOf('c')] = 'p'
|
||||
|
||||
const child = spawn('pg_dump', args, { env })
|
||||
return child
|
||||
}
|
||||
|
||||
export async function createPgDumpBuffer(db: Database): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('pg_dump', [
|
||||
'-h', db.host,
|
||||
'-p', db.port.toString(),
|
||||
'-U', db.username,
|
||||
'-d', db.database,
|
||||
'-F', 'p',
|
||||
'--no-owner',
|
||||
], {
|
||||
env: { ...process.env, PGPASSWORD: db.password },
|
||||
})
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
let errorOutput = ''
|
||||
|
||||
child.stdout.on('data', (data) => chunks.push(data))
|
||||
child.stderr.on('data', (data) => { errorOutput += data.toString() })
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(Buffer.concat(chunks))
|
||||
} else {
|
||||
reject(new Error(`pg_dump exited with code ${code}: ${errorOutput}`))
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(new Error(`Failed to start pg_dump: ${err.message}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadToDropbox(token: string, filename: string, content: Buffer) {
|
||||
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Dropbox-API-Arg': JSON.stringify({
|
||||
path: `/${filename}`,
|
||||
mode: 'add',
|
||||
autorename: true,
|
||||
mute: false
|
||||
}),
|
||||
'Content-Type': 'application/octet-stream'
|
||||
},
|
||||
body: new Uint8Array(content)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text()
|
||||
throw new Error(`Dropbox upload failed: ${response.status} ${errText}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export async function uploadToGoogleDrive(serviceAccountJsonStr: string, filename: string, content: Buffer) {
|
||||
const credentials = JSON.parse(serviceAccountJsonStr)
|
||||
|
||||
// 1. Get Access Token using jose
|
||||
const privateKey = await importPKCS8(credentials.private_key, 'RS256')
|
||||
const jwt = await new SignJWT({
|
||||
iss: credentials.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/drive.file',
|
||||
aud: 'https://oauth2.googleapis.com/token'
|
||||
})
|
||||
.setProtectedHeader({ alg: 'RS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(privateKey)
|
||||
|
||||
const tokenReq = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: jwt
|
||||
})
|
||||
})
|
||||
|
||||
if (!tokenReq.ok) {
|
||||
throw new Error(`Failed to get Google OAuth token: ${await tokenReq.text()}`)
|
||||
}
|
||||
|
||||
const { access_token } = await tokenReq.json()
|
||||
|
||||
// 2. Upload to Drive (Multipart upload to set filename)
|
||||
const boundary = '-------314159265358979323846'
|
||||
const delimiter = `\r\n--${boundary}\r\n`
|
||||
const close_delim = `\r\n--${boundary}--`
|
||||
|
||||
const metadata = {
|
||||
name: filename,
|
||||
mimeType: 'application/sql'
|
||||
}
|
||||
|
||||
const multipartRequestBody = Buffer.concat([
|
||||
Buffer.from(delimiter + 'Content-Type: application/json\r\n\r\n' + JSON.stringify(metadata) + '\r\n'),
|
||||
Buffer.from(delimiter + 'Content-Type: application/sql\r\n\r\n'),
|
||||
content,
|
||||
Buffer.from(close_delim)
|
||||
])
|
||||
|
||||
const response = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': `multipart/related; boundary=${boundary}`
|
||||
},
|
||||
body: new Uint8Array(multipartRequestBody)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Google Drive upload failed: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as cron from 'node-cron'
|
||||
import pool from './appDb'
|
||||
import { readConfig } from './config'
|
||||
import { createPgDumpBuffer, uploadToDropbox, uploadToGoogleDrive } from './backup'
|
||||
|
||||
export function startBackupCron() {
|
||||
console.log('[AutoBackup] Arka plan cron servisi başlatıldı.')
|
||||
|
||||
// Her dakika cron kurallarını ve görevleri çalıştıracak mantık
|
||||
// Aslında node-cron'u her backup_config için ayrı cron olarak register etmek daha doğru.
|
||||
// Ancak dinamik eklendiğinde/silindiğinde cron'ları güncellemek gerekir.
|
||||
// Basitlik için her dakika DB'den konfigürasyonları okuyup, node-cron syntax'ının "match" edip etmediğine bakacağız,
|
||||
// ya da her eklenen config için memory'de bir cron job tutacağız.
|
||||
// En kolayı, veritabanını saatte bir kontrol edip zamanlamaları işletmek, ya da memory'de cron scheduler tutmaktır.
|
||||
}
|
||||
|
||||
// memory map of active tasks
|
||||
const activeTasks = new Map<string, cron.ScheduledTask>()
|
||||
|
||||
export async function reloadBackupCrons() {
|
||||
// Clear existing
|
||||
for (const [id, task] of activeTasks.entries()) {
|
||||
task.stop()
|
||||
}
|
||||
activeTasks.clear()
|
||||
|
||||
try {
|
||||
const config = await readConfig()
|
||||
const { rows } = await pool.query(`SELECT * FROM backup_configs`)
|
||||
|
||||
for (const row of rows) {
|
||||
const db = config.databases.find(d => d.id === row.db_id)
|
||||
if (!db) continue // Veritabanı silinmiş
|
||||
|
||||
const task = cron.schedule(row.schedule, async () => {
|
||||
try {
|
||||
console.log(`[Backup] ${db.name} (${row.cloud_type}) başlatılıyor...`)
|
||||
|
||||
const buffer = await createPgDumpBuffer(db)
|
||||
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${db.name}_${dateStr}.sql`
|
||||
let message = 'Success'
|
||||
|
||||
try {
|
||||
if (row.cloud_type === 'dropbox') {
|
||||
await uploadToDropbox(row.credentials, filename, buffer)
|
||||
} else if (row.cloud_type === 'gdrive') {
|
||||
await uploadToGoogleDrive(row.credentials, filename, buffer)
|
||||
}
|
||||
} catch (uploadErr: any) {
|
||||
console.error(`[Backup] Upload hatası:`, uploadErr)
|
||||
message = `Upload failed: ${uploadErr.message}`
|
||||
throw uploadErr // fail the backup log
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
|
||||
[db.id, 'success', message, buffer.length]
|
||||
)
|
||||
console.log(`[Backup] ${db.name} başarıyla tamamlandı.`)
|
||||
|
||||
} catch (e: any) {
|
||||
console.error(`[Backup] Hata (${db.name}):`, e)
|
||||
await pool.query(
|
||||
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
|
||||
[db.id, 'error', e.message || String(e), 0]
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
activeTasks.set(row.id, task)
|
||||
}
|
||||
|
||||
console.log(`[AutoBackup] ${activeTasks.size} cron görevi yüklendi.`)
|
||||
} catch (e) {
|
||||
console.error(`[AutoBackup] Cronlar yüklenirken hata:`, e)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user