feat: add cloud connections and update backups
This commit is contained in:
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { LayoutDashboard, Activity, Database, LineChart, Server, LogOut, Box } from 'lucide-react'
|
||||
import { LayoutDashboard, Activity, Database, LineChart, Server, LogOut, Box, Cloud } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const nav = [
|
||||
@@ -11,6 +11,7 @@ const nav = [
|
||||
{ href: '/dashboard/analytics', label: 'Analytics', icon: LineChart },
|
||||
{ href: '/dashboard/services', label: 'Servisler', icon: Server },
|
||||
{ href: '/dashboard/docker', label: 'Docker', icon: Box },
|
||||
{ href: '/dashboard/connections', label: 'Bağlantılar', icon: Cloud },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
@@ -104,6 +104,16 @@ pool.query(`
|
||||
telegram_chat_id TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cloud_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
credentials TEXT NOT NULL DEFAULT '',
|
||||
default_target TEXT
|
||||
);
|
||||
|
||||
ALTER TABLE backup_configs ADD COLUMN IF NOT EXISTS connection_id TEXT;
|
||||
`).catch(console.error)
|
||||
export type PingLog = {
|
||||
id: number
|
||||
@@ -276,6 +286,37 @@ export async function getNotificationSettings(): Promise<NotificationSettings |
|
||||
return res.rows[0] ?? null
|
||||
}
|
||||
|
||||
export interface CloudConnection {
|
||||
id: string
|
||||
name: string
|
||||
type: 'gcs' | 'gdrive' | 'dropbox'
|
||||
credentials: string
|
||||
default_target: string | null
|
||||
}
|
||||
|
||||
export async function getCloudConnections(): Promise<CloudConnection[]> {
|
||||
const res = await pool.query(`SELECT * FROM cloud_connections ORDER BY name`)
|
||||
return res.rows as CloudConnection[]
|
||||
}
|
||||
|
||||
export async function getCloudConnection(id: string): Promise<CloudConnection | null> {
|
||||
const res = await pool.query(`SELECT * FROM cloud_connections WHERE id = $1`, [id])
|
||||
return (res.rows[0] as CloudConnection) ?? null
|
||||
}
|
||||
|
||||
export async function upsertCloudConnection(conn: CloudConnection): Promise<void> {
|
||||
await pool.query(`
|
||||
INSERT INTO cloud_connections (id, name, type, credentials, default_target)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = $2, type = $3, credentials = $4, default_target = $5
|
||||
`, [conn.id, conn.name, conn.type, conn.credentials, conn.default_target ?? null])
|
||||
}
|
||||
|
||||
export async function deleteCloudConnection(id: string): Promise<void> {
|
||||
await pool.query(`DELETE FROM cloud_connections WHERE id = $1`, [id])
|
||||
}
|
||||
|
||||
export async function saveNotificationSettings(s: Omit<NotificationSettings, never>): Promise<void> {
|
||||
await pool.query(`
|
||||
INSERT INTO notification_settings (id, webhook_url, telegram_token, telegram_chat_id, enabled)
|
||||
|
||||
+37
-8
@@ -151,8 +151,38 @@ export async function testGCSCredentials(
|
||||
// ─── Google Drive — OAuth2 (personal account) ────────────────────────────────
|
||||
// credentials JSON: { client_id, client_secret, refresh_token }
|
||||
|
||||
async function getOrCreateFolderOAuth(
|
||||
accessToken: string,
|
||||
folderName: string,
|
||||
parentId?: string
|
||||
): Promise<string> {
|
||||
const parentQ = parentId ? ` and '${parentId}' in parents` : ''
|
||||
const q = `name='${folderName.replace(/'/g, "\\'")}' and mimeType='application/vnd.google-apps.folder' and trashed=false${parentQ}`
|
||||
|
||||
const searchRes = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(q)}&fields=files(id,name)`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
)
|
||||
if (searchRes.ok) {
|
||||
const { files } = await searchRes.json()
|
||||
if (files?.length > 0) return files[0].id
|
||||
}
|
||||
|
||||
const meta: Record<string, unknown> = { name: folderName, mimeType: 'application/vnd.google-apps.folder' }
|
||||
if (parentId) meta.parents = [parentId]
|
||||
|
||||
const createRes = await fetch('https://www.googleapis.com/drive/v3/files?fields=id', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(meta),
|
||||
})
|
||||
if (!createRes.ok) throw new Error(`Drive klasör oluşturulamadı: ${await createRes.text()}`)
|
||||
return (await createRes.json()).id
|
||||
}
|
||||
|
||||
export async function uploadToGoogleDriveOAuth(
|
||||
credentialsJsonStr: string,
|
||||
dbName: string,
|
||||
filename: string,
|
||||
content: Buffer,
|
||||
parentFolderId?: string
|
||||
@@ -169,25 +199,24 @@ export async function uploadToGoogleDriveOAuth(
|
||||
const { access_token, error: tokenErr } = await tokenRes.json()
|
||||
if (tokenErr) throw new Error(`Drive token hatası: ${tokenErr}`)
|
||||
|
||||
// DB adında alt klasör bul veya oluştur (OAuth ile yapıldığı için kullanıcıya ait — kota sorunu yok)
|
||||
const targetFolderId = await getOrCreateFolderOAuth(access_token, dbName, parentFolderId || undefined)
|
||||
|
||||
const boundary = 'vps_panel_backup_boundary'
|
||||
const meta: Record<string, unknown> = { name: filename }
|
||||
if (parentFolderId) meta.parents = [parentFolderId]
|
||||
const meta = JSON.stringify({ name: filename, parents: [targetFolderId] })
|
||||
|
||||
const parts = Buffer.concat([
|
||||
Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(meta)}\r\n`),
|
||||
Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n`),
|
||||
Buffer.from(`--${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`),
|
||||
content,
|
||||
Buffer.from(`\r\n--${boundary}--`),
|
||||
])
|
||||
|
||||
const response = await fetch(
|
||||
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,webViewLink',
|
||||
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
'Content-Type': `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': `multipart/related; boundary=${boundary}` },
|
||||
body: new Uint8Array(parts),
|
||||
}
|
||||
)
|
||||
|
||||
+40
-26
@@ -1,5 +1,6 @@
|
||||
import * as cron from 'node-cron'
|
||||
import pool from './appDb'
|
||||
import { getCloudConnection } from './appDb'
|
||||
import { readConfig } from './config'
|
||||
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from './backup'
|
||||
|
||||
@@ -9,48 +10,61 @@ export function startBackupCron() {
|
||||
|
||||
const activeTasks = new Map<string, cron.ScheduledTask>()
|
||||
|
||||
export async function reloadBackupCrons() {
|
||||
for (const [id, task] of activeTasks.entries()) {
|
||||
task.stop()
|
||||
async function runUpload(row: any, db: { name: string }, buffer: Buffer, filename: string) {
|
||||
// connection_id varsa global bağlantıdan al, yoksa eski inline credentials kullan
|
||||
let type = row.cloud_type as string
|
||||
let credentials = row.credentials as string
|
||||
let target = (row.gdrive_folder_id as string) || ''
|
||||
|
||||
if (row.connection_id) {
|
||||
const conn = await getCloudConnection(row.connection_id)
|
||||
if (!conn) throw new Error(`Cloud bağlantısı bulunamadı: ${row.connection_id}`)
|
||||
type = conn.type
|
||||
credentials = conn.credentials
|
||||
// Per-DB override yoksa bağlantının default_target'ını kullan
|
||||
if (!target && conn.default_target) target = conn.default_target
|
||||
}
|
||||
|
||||
if (type === 'dropbox') {
|
||||
await uploadToDropbox(credentials.trim(), db.name, filename, buffer)
|
||||
} 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 {
|
||||
throw new Error(`Bilinmeyen cloud tipi: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadBackupCrons() {
|
||||
for (const [, 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
|
||||
|
||||
const task = cron.schedule(row.schedule, async () => {
|
||||
try {
|
||||
console.log(`[Backup] ${db.name} (${row.cloud_type}) başlatılıyor...`)
|
||||
|
||||
const buffer = await createDbDumpBuffer(db)
|
||||
const filename = backupFilename(db.name)
|
||||
let message = 'Success'
|
||||
const effectiveType = row.connection_id
|
||||
? (await getCloudConnection(row.connection_id))?.type ?? row.cloud_type
|
||||
: row.cloud_type
|
||||
|
||||
try {
|
||||
if (row.cloud_type === 'dropbox') {
|
||||
await uploadToDropbox(row.credentials, db.name, filename, buffer)
|
||||
} else if (row.cloud_type === 'gcs') {
|
||||
await uploadToGCS(row.credentials, row.gdrive_folder_id, db.name, filename, buffer)
|
||||
} else if (row.cloud_type === 'gdrive') {
|
||||
await uploadToGoogleDriveOAuth(row.credentials, filename, buffer, row.gdrive_folder_id || undefined)
|
||||
}
|
||||
} catch (uploadErr: any) {
|
||||
console.error(`[Backup] Upload hatası:`, uploadErr)
|
||||
message = `Upload failed: ${uploadErr.message}`
|
||||
throw uploadErr // fail the backup log
|
||||
}
|
||||
console.log(`[Backup] ${db.name} (${effectiveType}) başlatılıyor...`)
|
||||
try {
|
||||
const buffer = await createDbDumpBuffer(db)
|
||||
const filename = effectiveType === 'gdrive' ? backupFilename() : backupFilename(db.name)
|
||||
|
||||
await runUpload(row, db, buffer, filename)
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
|
||||
[db.id, 'success', message, buffer.length]
|
||||
[db.id, 'success', 'Otomatik yedek', buffer.length]
|
||||
)
|
||||
console.log(`[Backup] ${db.name} başarıyla tamamlandı.`)
|
||||
|
||||
console.log(`[Backup] ${db.name} tamamlandı.`)
|
||||
} catch (e: any) {
|
||||
console.error(`[Backup] Hata (${db.name}):`, e)
|
||||
await pool.query(
|
||||
|
||||
Reference in New Issue
Block a user