feat: add cloud connections and update backups

This commit is contained in:
mstfyldz
2026-06-06 00:31:10 +03:00
parent 58d7fffc6b
commit a4c58135df
11 changed files with 589 additions and 221 deletions
+12 -9
View File
@@ -33,24 +33,27 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { db_id, schedule, cloud_type, credentials, gdrive_folder_id } = body
const { db_id, schedule, connection_id, cloud_type, credentials, gdrive_folder_id } = body
if (!db_id || !schedule || !cloud_type || !credentials) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
if (!db_id || !schedule) {
return NextResponse.json({ error: 'db_id ve schedule zorunlu' }, { status: 400 })
}
// connection_id VEYA credentials zorunlu
if (!connection_id && !credentials) {
return NextResponse.json({ error: 'connection_id veya credentials gerekli' }, { 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, gdrive_folder_id=$4 WHERE db_id=$5`,
[schedule, cloud_type, credentials, gdrive_folder_id || null, db_id]
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4, connection_id=$5 WHERE db_id=$6`,
[schedule, cloud_type || '', credentials || '', gdrive_folder_id || null, connection_id || null, db_id]
)
} else {
await pool.query(
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id) VALUES ($1, $2, $3, $4, $5, $6)`,
[generateId(), db_id, schedule, cloud_type, credentials, gdrive_folder_id || null]
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[generateId(), db_id, schedule, cloud_type || '', credentials || '', gdrive_folder_id || null, connection_id || null]
)
}
+28 -18
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { readConfig } from '@/lib/config'
import { getCloudConnection } from '@/lib/appDb'
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from '@/lib/backup'
import pool from '@/lib/appDb'
@@ -8,47 +9,56 @@ export async function POST(req: NextRequest) {
const authErr = await requireAuth(req)
if (authErr) return authErr
const { db_id, cloud_type, credentials, gdrive_folder_id } = await req.json()
if (!credentials?.trim()) return NextResponse.json({ ok: false, error: 'Credentials boş olamaz.' })
const body = await req.json()
const { db_id, connection_id, cloud_type: inlineType, credentials: inlineCredentials, gdrive_folder_id } = body
const config = await readConfig()
const db = config.databases.find(d => d.id === db_id)
if (!db) return NextResponse.json({ ok: false, error: 'Veritabanı bulunamadı.' })
const filename = backupFilename(db.name)
// Bağlantı kaynağı: global connection veya inline
let type: string, credentials: string, target: string
if (connection_id) {
const conn = await getCloudConnection(connection_id)
if (!conn) return NextResponse.json({ ok: false, error: 'Cloud bağlantısı bulunamadı.' })
type = conn.type
credentials = conn.credentials
target = gdrive_folder_id || conn.default_target || ''
} else {
if (!inlineCredentials?.trim()) return NextResponse.json({ ok: false, error: 'Credentials boş.' })
type = inlineType
credentials = inlineCredentials
target = gdrive_folder_id || ''
}
const filename = type === 'gdrive' ? backupFilename() : backupFilename(db.name)
try {
const buffer = await createDbDumpBuffer(db)
if (cloud_type === 'dropbox') {
if (type === 'dropbox') {
await uploadToDropbox(credentials.trim(), db.name, filename, buffer)
} else if (cloud_type === 'gcs') {
await uploadToGCS(credentials, gdrive_folder_id, db.name, filename, buffer)
} else if (cloud_type === 'gdrive') {
await uploadToGoogleDriveOAuth(credentials, filename, buffer, gdrive_folder_id || undefined)
} else if (type === 'gcs') {
await uploadToGCS(credentials, target, db.name, filename, buffer)
} else if (type === 'gdrive') {
await uploadToGoogleDriveOAuth(credentials, db.name, filename, buffer, target || undefined)
} else {
return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
}
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db_id, 'success', `Manuel yedek — ${cloud_type}`, buffer.length]
[db_id, 'success', `Manuel yedek — ${type}`, buffer.length]
)
const sizeStr = buffer.length > 1024 * 1024
? `${(buffer.length / (1024 * 1024)).toFixed(2)} MB`
: `${(buffer.length / 1024).toFixed(1)} KB`
return NextResponse.json({
ok: true,
detail: `✓ Yedek yüklendi — ${db.name}/${filename} (${sizeStr})`,
})
const pathStr = type === 'gdrive' ? `${db.name}/${filename}` : filename
return NextResponse.json({ ok: true, detail: `✓ Yüklendi — ${pathStr} (${sizeStr})` })
} catch (e: any) {
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db_id, 'error', e.message, 0]
).catch(() => {})
await pool.query(`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, [db_id, 'error', e.message, 0]).catch(() => {})
return NextResponse.json({ ok: false, error: e.message })
}
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCloudConnection, upsertCloudConnection } from '@/lib/appDb'
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const code = searchParams.get('code')
const connection_id = searchParams.get('state')
const error = searchParams.get('error')
const proto = req.headers.get('x-forwarded-proto') ?? req.nextUrl.protocol.replace(':', '')
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? req.nextUrl.host
const origin = `${proto}://${host}`
const redirectUri = `${origin}/api/cloud-connections/gdrive/callback`
const connectionsUrl = `${origin}/dashboard/connections`
if (error) return NextResponse.redirect(`${connectionsUrl}?gdrive_error=${encodeURIComponent(error)}`)
if (!code || !connection_id) return NextResponse.redirect(`${connectionsUrl}?gdrive_error=missing_params`)
const conn = await getCloudConnection(connection_id)
if (!conn) return NextResponse.redirect(`${connectionsUrl}?gdrive_error=connection_not_found`)
let client_id: string, client_secret: string
try { const c = JSON.parse(conn.credentials); client_id = c.client_id; client_secret = c.client_secret }
catch { return NextResponse.redirect(`${connectionsUrl}?gdrive_error=invalid_credentials`) }
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ code, client_id, client_secret, redirect_uri: redirectUri, grant_type: 'authorization_code' }),
})
if (!tokenRes.ok) {
const e = await tokenRes.text()
return NextResponse.redirect(`${connectionsUrl}?gdrive_error=${encodeURIComponent(e.slice(0, 80))}`)
}
const tokens = await tokenRes.json()
if (!tokens.refresh_token) return NextResponse.redirect(`${connectionsUrl}?gdrive_error=no_refresh_token`)
await upsertCloudConnection({
...conn,
credentials: JSON.stringify({ client_id, client_secret, refresh_token: tokens.refresh_token }),
})
return NextResponse.redirect(`${connectionsUrl}?gdrive_connected=${connection_id}`)
}
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getCloudConnection, upsertCloudConnection } from '@/lib/appDb'
function panelOrigin(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? req.nextUrl.protocol.replace(':', '')
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? req.nextUrl.host
return `${proto}://${host}`
}
// GET /api/cloud-connections/gdrive?connection_id=xxx → Google OAuth redirect
export async function GET(req: NextRequest) {
const authErr = await requireAuth(req)
if (authErr) return authErr
const connection_id = req.nextUrl.searchParams.get('connection_id')
if (!connection_id) return NextResponse.json({ error: 'connection_id gerekli' }, { status: 400 })
const conn = await getCloudConnection(connection_id)
if (!conn) return NextResponse.json({ error: 'Bağlantı bulunamadı' }, { status: 404 })
let client_id: string
try { client_id = JSON.parse(conn.credentials).client_id }
catch { return NextResponse.json({ error: 'Credentials geçersiz' }, { status: 400 }) }
const origin = panelOrigin(req)
const redirectUri = `${origin}/api/cloud-connections/gdrive/callback`
const oauthUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({
client_id,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/drive.file',
access_type: 'offline',
prompt: 'consent',
state: connection_id,
})
return NextResponse.redirect(oauthUrl)
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getCloudConnections, upsertCloudConnection, deleteCloudConnection } from '@/lib/appDb'
import { generateId } from '@/lib/config'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
return NextResponse.json(await getCloudConnections())
}
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const body = await req.json()
const conn = { id: generateId(), name: body.name, type: body.type, credentials: body.credentials ?? '', default_target: body.default_target ?? null }
await upsertCloudConnection(conn)
return NextResponse.json(conn)
}
export async function PUT(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const body = await req.json()
if (!body.id) return NextResponse.json({ error: 'id gerekli' }, { status: 400 })
await upsertCloudConnection({ id: body.id, name: body.name, type: body.type, credentials: body.credentials ?? '', default_target: body.default_target ?? null })
return NextResponse.json({ ok: true })
}
export async function DELETE(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const id = req.nextUrl.searchParams.get('id')
if (!id) return NextResponse.json({ error: 'id gerekli' }, { status: 400 })
await deleteCloudConnection(id)
return NextResponse.json({ ok: true })
}
+244
View File
@@ -0,0 +1,244 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, CheckCircle2, Link2, RefreshCw, ShieldAlert, Copy } from 'lucide-react'
type Conn = { id: string; name: string; type: 'gcs' | 'gdrive' | 'dropbox'; credentials: string; default_target: string | null }
const TYPE_LABELS: Record<string, string> = { gcs: '☁️ Google Cloud Storage', gdrive: '📁 Google Drive', dropbox: '📦 Dropbox' }
function isGdriveConnected(credentials: string) {
try { return !!JSON.parse(credentials).refresh_token } catch { return false }
}
function parseGdriveCreds(credentials: string) {
try { const c = JSON.parse(credentials); return { client_id: c.client_id || '', client_secret: c.client_secret || '' } }
catch { return { client_id: '', client_secret: '' } }
}
export default function ConnectionsPage() {
const [conns, setConns] = useState<Conn[]>([])
const [showAdd, setShowAdd] = useState(false)
const [editConn, setEditConn] = useState<Conn | null>(null)
const [notice, setNotice] = useState<{ ok: boolean; msg: string } | null>(null)
const load = useCallback(async () => {
const r = await fetch('/api/cloud-connections')
if (r.ok) setConns(await r.json())
}, [])
useEffect(() => {
load()
const params = new URLSearchParams(window.location.search)
if (params.get('gdrive_connected')) {
setNotice({ ok: true, msg: 'Google Drive bağlantısı başarıyla kuruldu.' })
window.history.replaceState({}, '', window.location.pathname)
load()
} else if (params.get('gdrive_error')) {
setNotice({ ok: false, msg: `Google bağlantı hatası: ${params.get('gdrive_error')}` })
window.history.replaceState({}, '', window.location.pathname)
}
}, [load])
const del = async (id: string) => {
if (!confirm('Bu bağlantıyı sil?')) return
await fetch(`/api/cloud-connections?id=${id}`, { method: 'DELETE' })
load()
}
return (
<div style={{ padding: 28, maxWidth: 900 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Cloud Bağlantıları</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Bir kere kur, tüm DB yedekleri için kullan</p>
</div>
<Button onClick={() => setShowAdd(true)} className="gap-1.5"><Plus className="w-4 h-4" /> Yeni Bağlantı</Button>
</div>
{notice && (
<div className={`flex items-center gap-2.5 rounded-xl border px-4 py-3 mb-6 text-sm font-mono ${notice.ok ? 'border-success/30 bg-success/5 text-success' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
{notice.ok ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <ShieldAlert className="w-4 h-4 shrink-0" />}
{notice.msg}
<button onClick={() => setNotice(null)} className="ml-auto opacity-60 hover:opacity-100"></button>
</div>
)}
{conns.length === 0 ? (
<Card className="border-dashed border-2 bg-surface/30">
<CardContent className="flex flex-col items-center py-16 text-center">
<Link2 className="w-10 h-10 text-muted/30 mb-4" />
<div className="text-sm font-semibold mb-2">Henüz bağlantı yok</div>
<p className="text-xs text-muted mb-5">GCS, Google Drive veya Dropbox bağlantısı ekle.</p>
<Button onClick={() => setShowAdd(true)}><Plus className="w-4 h-4 mr-1.5" /> Ekle</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{conns.map(conn => {
const gdriveOk = conn.type === 'gdrive' && isGdriveConnected(conn.credentials)
return (
<Card key={conn.id} className="bg-surface/50">
<CardContent className="p-5 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<span className="font-semibold text-sm">{conn.name}</span>
<span className="text-[10px] font-mono bg-white/5 border border-border/50 px-2 py-0.5 rounded-full text-muted">
{TYPE_LABELS[conn.type] ?? conn.type}
</span>
{conn.type === 'gdrive' && (
<span className={`text-[10px] font-mono px-2 py-0.5 rounded-full border ${gdriveOk ? 'bg-success/10 border-success/30 text-success' : 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'}`}>
{gdriveOk ? '✓ Bağlı' : '⚠ Yetki Yok'}
</span>
)}
</div>
<div className="text-[11px] text-muted font-mono">
{conn.default_target ? `Hedef: ${conn.default_target}` : 'Varsayılan hedef yok'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{conn.type === 'gdrive' && (
<Button variant="outline" size="sm" className="h-8 text-xs gap-1.5"
onClick={() => { window.location.href = `/api/cloud-connections/gdrive?connection_id=${conn.id}` }}>
<RefreshCw className="w-3 h-3" /> {gdriveOk ? 'Yenile' : 'Bağlan'}
</Button>
)}
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setEditConn(conn)}>Düzenle</Button>
<button onClick={() => del(conn.id)} className="p-1.5 text-muted hover:text-destructive transition-colors rounded">
<Trash2 className="w-4 h-4" />
</button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{(showAdd || editConn) && (
<ConnModal
conn={editConn}
onClose={() => { setShowAdd(false); setEditConn(null) }}
onSaved={() => { load(); setShowAdd(false); setEditConn(null) }}
/>
)}
</div>
)
}
function ConnModal({ conn, onClose, onSaved }: { conn: Conn | null; onClose: () => void; onSaved: () => void }) {
const [type, setType] = useState<'gcs' | 'gdrive' | 'dropbox'>(conn?.type ?? 'gcs')
const [name, setName] = useState(conn?.name ?? '')
const [credentials, setCredentials] = useState(conn?.type === 'gdrive' ? '' : (conn?.credentials ?? ''))
const [defaultTarget, setDefaultTarget] = useState(conn?.default_target ?? '')
const [clientId, setClientId] = useState(() => parseGdriveCreds(conn?.credentials ?? '').client_id)
const [clientSecret, setClientSecret] = useState(() => parseGdriveCreds(conn?.credentials ?? '').client_secret)
const [saving, setSaving] = useState(false)
const [copiedUri, setCopiedUri] = useState(false)
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/api/cloud-connections/gdrive/callback' : ''
const submit = async () => {
setSaving(true)
const creds = type === 'gdrive'
? (() => { try { const existing = JSON.parse(conn?.credentials ?? '{}'); return JSON.stringify({ ...existing, client_id: clientId, client_secret: clientSecret }) } catch { return JSON.stringify({ client_id: clientId, client_secret: clientSecret }) } })()
: credentials
const method = conn ? 'PUT' : 'POST'
const body = { id: conn?.id, name, type, credentials: creds, default_target: defaultTarget || null }
const r = await fetch('/api/cloud-connections', { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
setSaving(false)
if (r.ok) onSaved()
}
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[520px] shadow-2xl" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/50">
<CardTitle>{conn ? 'Bağlantı Düzenle' : 'Yeni Cloud Bağlantısı'}</CardTitle>
</CardHeader>
<CardContent className="pt-5 space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bağlantı Adı</label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="Production Drive, Backup GCS…" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Tür</label>
<div className="flex gap-2">
{(['gcs', 'gdrive', 'dropbox'] as const).map(t => (
<button key={t} onClick={() => setType(t)}
className={`flex-1 py-2 rounded-lg border text-xs font-medium transition-all ${type === t ? 'bg-accent/10 border-accent/40 text-accent' : 'border-border/50 text-muted hover:text-text hover:border-border'}`}>
{TYPE_LABELS[t]}
</button>
))}
</div>
</div>
{/* GCS */}
{type === 'gcs' && (
<>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Service Account JSON</label>
<textarea className="flex w-full rounded-md border border-border bg-black/30 px-3 py-2 text-xs font-mono resize-y min-h-[90px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" value={credentials} onChange={e => setCredentials(e.target.value)} placeholder={'{\n "type": "service_account",\n ...\n}'} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Varsayılan Bucket</label>
<Input value={defaultTarget} onChange={e => setDefaultTarget(e.target.value)} placeholder="my-backups-bucket" />
</div>
</>
)}
{/* Dropbox */}
{type === 'dropbox' && (
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Access Token</label>
<Input value={credentials} onChange={e => setCredentials(e.target.value)} placeholder="sl.xxxxxxxxxxxxxxxx..." />
<div className="text-[10px] text-muted/60 font-mono">dropbox.com/developers Apps Generate token</div>
</div>
)}
{/* Google Drive */}
{type === 'gdrive' && (
<>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Authorized Redirect URI (Google Console'a ekle)</label>
<div className="flex items-center gap-2">
<div className="flex-1 rounded-md border border-accent/30 bg-accent/5 px-3 py-2 text-xs font-mono text-accent truncate">{redirectUri}</div>
<button onClick={() => { navigator.clipboard.writeText(redirectUri); setCopiedUri(true); setTimeout(() => setCopiedUri(false), 2000) }}
className="p-2 rounded border border-border hover:bg-surface-2 text-muted hover:text-text shrink-0 transition-colors">
{copiedUri ? <CheckCircle2 className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client ID</label>
<Input value={clientId} onChange={e => setClientId(e.target.value)} placeholder="xxxxxxx.apps.googleusercontent.com" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client Secret</label>
<Input type="password" value={clientSecret} onChange={e => setClientSecret(e.target.value)} placeholder="GOCSPX-..." />
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Varsayılan Klasör ID (Opsiyonel)</label>
<Input value={defaultTarget} onChange={e => setDefaultTarget(e.target.value)} placeholder="1A2b3C4d5E6f7G8h9I0j..." />
</div>
<div className="rounded-lg bg-yellow-500/5 border border-yellow-500/20 px-3 py-2.5 text-[11px] font-mono text-yellow-200/70">
Kaydet → ardından "Bağlan" butonuyla Google hesabını yetkilendir.
</div>
</>
)}
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button>
<Button onClick={submit} disabled={saving || !name} className="flex-1">
{saving ? 'Kaydediliyor...' : conn ? 'Güncelle' : 'Kaydet'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+62 -159
View File
@@ -435,68 +435,51 @@ const CRON_PRESETS = [
function BackupTab({ db }: { db: Db }) {
const [config, setConfig] = useState<any>(null)
const [logs, setLogs] = useState<any[]>([])
const [connections, setConnections] = useState<{ id: string; name: string; type: string }[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; detail?: string; error?: string } | null>(null)
const [copiedUri, setCopiedUri] = useState(false)
const [redirectUri, setRedirectUri] = useState('')
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'gcs', credentials: '', gdrive_folder_id: '' })
const [f, setF] = useState({ schedule: '0 0 * * *', connection_id: '', target_override: '' })
const load = useCallback(async () => {
setLoading(true)
const r = await fetch(`/api/backups?dbId=${db.id}`)
if (r.ok) {
const data = await r.json()
const [backupRes, connsRes] = await Promise.all([
fetch(`/api/backups?dbId=${db.id}`),
fetch('/api/cloud-connections'),
])
if (connsRes.ok) setConnections(await connsRes.json())
if (backupRes.ok) {
const data = await backupRes.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, gdrive_folder_id: data.config.gdrive_folder_id || '' })
if (data.config) {
setF({ schedule: data.config.schedule, connection_id: data.config.connection_id || '', target_override: data.config.gdrive_folder_id || '' })
}
}
setLoading(false)
}, [db.id])
useEffect(() => {
load()
setRedirectUri(window.location.origin + '/api/backups/gdrive/callback')
// URL param kontrol — Google OAuth callback sonrası
const params = new URLSearchParams(window.location.search)
if (params.get('gdrive_connected') === db.id) {
window.history.replaceState({}, '', window.location.pathname)
load()
} else if (params.get('gdrive_error')) {
setTestResult({ ok: false, error: `Google bağlantı hatası: ${params.get('gdrive_error')}` })
window.history.replaceState({}, '', window.location.pathname)
}
}, [load, db.id])
useEffect(() => { load() }, [load])
// gdrive credentials yardımcıları
const gdriveFields = (() => {
try { const c = JSON.parse(f.credentials); return { client_id: c.client_id || '', client_secret: c.client_secret || '', connected: !!c.refresh_token } }
catch { return { client_id: '', client_secret: '', connected: false } }
})()
const updateGdriveField = (key: string, val: string) => {
try { const c = JSON.parse(f.credentials); setF(p => ({ ...p, credentials: JSON.stringify({ ...c, [key]: val }) })) }
catch { setF(p => ({ ...p, credentials: JSON.stringify({ [key]: val }) })) }
}
const selectedConn = connections.find(c => c.id === f.connection_id)
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 fetch('/api/backups', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id, schedule: f.schedule, connection_id: f.connection_id, gdrive_folder_id: f.target_override, credentials: '_via_connection_' }),
})
await load()
setSaving(false)
}
const connectGDrive = async () => {
setSaving(true)
await fetch('/api/backups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id, ...f }) })
setSaving(false)
window.location.href = `/api/backups/gdrive/connect?db_id=${db.id}`
}
const testNow = async () => {
setTesting(true); setTestResult(null)
const r = await fetch('/api/backups/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id, cloud_type: f.cloud_type, credentials: f.credentials, gdrive_folder_id: f.gdrive_folder_id }) })
const r = await fetch('/api/backups/test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id, connection_id: f.connection_id, gdrive_folder_id: f.target_override }),
})
const result = await r.json()
setTestResult(result)
if (result.ok) await load()
@@ -507,14 +490,11 @@ function BackupTab({ db }: { db: Db }) {
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: 'gcs', credentials: '', gdrive_folder_id: '' })
setF({ schedule: '0 0 * * *', connection_id: '', target_override: '' })
}
if (loading) return <div className="text-muted text-sm font-mono animate-pulse">Yükleniyor...</div>
const isGdrive = f.cloud_type === 'gdrive'
const credentialsReady = isGdrive ? gdriveFields.connected : !!f.credentials.trim()
return (
<div className="flex gap-6 items-start fade-up">
<div className="flex-1 space-y-4">
@@ -539,113 +519,45 @@ function BackupTab({ db }: { db: Db }) {
</CardHeader>
<CardContent className="space-y-5">
{/* Provider seçimi */}
{/* Bağlantı seçimi */}
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Depolama</label>
<div className="flex gap-2">
{[{ id: 'gcs', label: '☁️ Google Cloud Storage' }, { id: 'dropbox', label: '📦 Dropbox' }, { id: 'gdrive', label: '📁 Google Drive' }].map(p => (
<button key={p.id} onClick={() => { setF(prev => ({ ...prev, cloud_type: p.id, credentials: '', gdrive_folder_id: '' })); setTestResult(null) }}
className={`flex-1 py-2 rounded-lg border text-xs font-medium transition-all ${f.cloud_type === p.id ? 'bg-accent/10 border-accent/40 text-accent' : 'border-border/50 text-muted hover:text-text hover:border-border'}`}>
{p.label}
</button>
))}
<div className="flex items-center justify-between">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Cloud Bağlantısı</label>
<a href="/dashboard/connections" className="text-[10px] font-mono text-accent hover:underline">+ Yeni Bağlantı </a>
</div>
{connections.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-4 py-5 text-center">
<div className="text-xs text-muted font-mono mb-3">Henüz cloud bağlantısı yok.</div>
<a href="/dashboard/connections" className="text-xs text-accent hover:underline font-mono">Bağlantılar sayfasında ekle </a>
</div>
) : (
<select
value={f.connection_id}
onChange={e => { setF(p => ({ ...p, connection_id: e.target.value })); setTestResult(null) }}
className="flex h-9 w-full rounded-md border border-border bg-black/30 px-3 py-1 text-sm shadow-sm font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent"
>
<option value=""> Seç </option>
{connections.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.type})</option>
))}
</select>
)}
</div>
{/* ── GCS ─────────────────────────────────────────────────────── */}
{f.cloud_type === 'gcs' && (
<>
<StepGuide steps={[
{ n: 1, text: 'console.cloud.google.com → Proje seç/oluştur' },
{ n: 2, text: '"APIs & Services" → "Cloud Storage API" → Enable' },
{ n: 3, text: '"Cloud Storage" → "Buckets" → Yeni bucket oluştur → adını not et' },
{ n: 4, text: '"IAM & Admin" → "Service Accounts" → Hesap oluştur → JSON key indir', hi: true },
{ n: 5, text: 'Bucket → "Permissions" → "Grant Access" → service account e-postası → "Storage Object Admin"', hi: true },
{ n: 6, text: 'Bucket adını aşağıya gir, JSON\'ı yapıştır → Şimdi Yedekle' },
]} />
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bucket Adı</label>
<Input value={f.gdrive_folder_id} onChange={e => setF(p => ({ ...p, gdrive_folder_id: e.target.value }))} placeholder="vps-backups" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Service Account JSON</label>
<textarea className="flex w-full rounded-md border border-border bg-black/30 px-3 py-2 text-xs font-mono resize-y min-h-[100px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" value={f.credentials} onChange={e => { setF(p => ({ ...p, credentials: e.target.value })); setTestResult(null) }} placeholder={'{\n "type": "service_account",\n "client_email": "xxx@project.iam.gserviceaccount.com",\n ...\n}'} />
</div>
</>
)}
{/* ── Dropbox ──────────────────────────────────────────────────── */}
{f.cloud_type === 'dropbox' && (
{/* Per-DB hedef override */}
{selectedConn && (
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Access Token</label>
<Input value={f.credentials} onChange={e => { setF(p => ({ ...p, credentials: e.target.value })); setTestResult(null) }} placeholder="sl.xxxxxxxxxxxxxxxx..." />
<div className="text-[10px] text-muted/60 font-mono">dropbox.com/developers Apps Create app Generate token</div>
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">
{selectedConn.type === 'gcs' ? 'Bucket Override' : selectedConn.type === 'gdrive' ? 'Klasör ID Override' : 'Hedef Override'} (Opsiyonel)
</label>
<Input
value={f.target_override}
onChange={e => setF(p => ({ ...p, target_override: e.target.value }))}
placeholder="Boş bırakırsan bağlantının varsayılan hedefi kullanılır"
/>
</div>
)}
{/* ── Google Drive OAuth ───────────────────────────────────────── */}
{isGdrive && (
<>
{gdriveFields.connected ? (
/* Bağlı durumu */
<div className="flex items-center justify-between rounded-xl border border-success/30 bg-success/5 px-4 py-3">
<div className="flex items-center gap-2.5">
<CheckCircle2 className="w-4 h-4 text-success" />
<div>
<div className="text-sm font-semibold text-success">Google hesabı bağlı</div>
<div className="text-[10px] text-muted font-mono mt-0.5">OAuth refresh token aktif</div>
</div>
</div>
<button onClick={connectGDrive} className="text-[10px] font-mono text-muted hover:text-text border border-border rounded px-2 py-1 transition-colors">
Yeniden Bağla
</button>
</div>
) : (
/* Bağlantı kurulum formu */
<>
<StepGuide steps={[
{ n: 1, text: 'console.cloud.google.com → Proje seç/oluştur' },
{ n: 2, text: '"APIs & Services" → "Enable APIs" → "Google Drive API" aç' },
{ n: 3, text: '"Credentials" → "Create Credentials" → "OAuth 2.0 Client IDs" → Web application seç' },
{ n: 4, text: '"Authorized redirect URIs" → aşağıdaki URL\'yi ekle', hi: true },
{ n: 5, text: 'Client ID ve Client Secret\'i kopyala → aşağıya yapıştır', hi: true },
{ n: 6, text: '"Google ile Bağlan" a tıkla → izin ver → otomatik geri dön' },
]} />
{/* Redirect URI göster */}
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Authorized Redirect URI (Google Console'a ekle)</label>
<div className="flex items-center gap-2">
<div className="flex-1 rounded-md border border-accent/30 bg-accent/5 px-3 py-2 text-xs font-mono text-accent truncate">{redirectUri}</div>
<button onClick={() => { navigator.clipboard.writeText(redirectUri); setCopiedUri(true); setTimeout(() => setCopiedUri(false), 2000) }}
className="p-2 rounded border border-border hover:bg-surface-2 text-muted hover:text-text transition-colors shrink-0">
{copiedUri ? <CheckCircle2 className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client ID</label>
<Input value={gdriveFields.client_id} onChange={e => updateGdriveField('client_id', e.target.value)} placeholder="xxxxxxx.apps.googleusercontent.com" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client Secret</label>
<Input type="password" value={gdriveFields.client_secret} onChange={e => updateGdriveField('client_secret', e.target.value)} placeholder="GOCSPX-..." />
</div>
</div>
</>
)}
{/* Klasör ID — her zaman göster */}
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Klasör ID (Opsiyonel)</label>
<Input value={f.gdrive_folder_id} onChange={e => setF(p => ({ ...p, gdrive_folder_id: e.target.value }))} placeholder="1A2b3C4d5E6f7G8h9I0j..." />
<div className="text-[10px] text-muted/60 font-mono">Belirtilmezse My Drive köküne yükler. Klasör URL'sindeki /folders/ID kısmı.</div>
</div>
</>
)}
{/* Test sonucu */}
{testResult && (
<div className={`flex items-start gap-2.5 rounded-lg border px-4 py-3 text-xs font-mono ${testResult.ok ? 'border-success/30 bg-success/5 text-success' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
@@ -670,23 +582,14 @@ function BackupTab({ db }: { db: Db }) {
{/* Butonlar */}
<div className="flex gap-2 pt-1">
{isGdrive && !gdriveFields.connected ? (
<Button onClick={connectGDrive} disabled={saving || !gdriveFields.client_id || !gdriveFields.client_secret} className="flex-1 gap-1.5">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
{saving ? 'Kaydediliyor...' : 'Google ile Bağlan →'}
</Button>
) : (
<>
<Button variant="outline" onClick={testNow} disabled={testing || !credentialsReady} className="gap-1.5" title="Şimdi yedek al ve yükle">
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
{testing ? 'Yedekleniyor...' : 'Şimdi Yedekle'}
</Button>
<Button onClick={saveConfig} disabled={saving || !credentialsReady} className="flex-1">
{saving ? 'Kaydediliyor...' : config ? 'Güncelle' : 'Etkinleştir'}
</Button>
{config && <Button variant="danger" onClick={deleteConfig}>Kapat</Button>}
</>
)}
<Button variant="outline" onClick={testNow} disabled={testing || !f.connection_id} className="gap-1.5">
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
{testing ? 'Yedekleniyor...' : 'Şimdi Yedekle'}
</Button>
<Button onClick={saveConfig} disabled={saving || !f.connection_id} className="flex-1">
{saving ? 'Kaydediliyor...' : config ? 'Güncelle' : 'Etkinleştir'}
</Button>
{config && <Button variant="danger" onClick={deleteConfig}>Kapat</Button>}
</div>
</CardContent>
</Card>