feat: add cloud connections and update backups
This commit is contained in:
@@ -33,24 +33,27 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json()
|
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) {
|
if (!db_id || !schedule) {
|
||||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
|
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])
|
const existing = await pool.query(`SELECT id FROM backup_configs WHERE db_id = $1`, [db_id])
|
||||||
|
|
||||||
if (existing.rows.length > 0) {
|
if (existing.rows.length > 0) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4 WHERE db_id=$5`,
|
`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, db_id]
|
[schedule, cloud_type || '', credentials || '', gdrive_folder_id || null, connection_id || null, db_id]
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id) VALUES ($1, $2, $3, $4, $5, $6)`,
|
`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]
|
[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 { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireAuth } from '@/lib/auth'
|
import { requireAuth } from '@/lib/auth'
|
||||||
import { readConfig } from '@/lib/config'
|
import { readConfig } from '@/lib/config'
|
||||||
|
import { getCloudConnection } from '@/lib/appDb'
|
||||||
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from '@/lib/backup'
|
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from '@/lib/backup'
|
||||||
import pool from '@/lib/appDb'
|
import pool from '@/lib/appDb'
|
||||||
|
|
||||||
@@ -8,47 +9,56 @@ export async function POST(req: NextRequest) {
|
|||||||
const authErr = await requireAuth(req)
|
const authErr = await requireAuth(req)
|
||||||
if (authErr) return authErr
|
if (authErr) return authErr
|
||||||
|
|
||||||
const { db_id, cloud_type, credentials, gdrive_folder_id } = await req.json()
|
const body = await req.json()
|
||||||
|
const { db_id, connection_id, cloud_type: inlineType, credentials: inlineCredentials, gdrive_folder_id } = body
|
||||||
if (!credentials?.trim()) return NextResponse.json({ ok: false, error: 'Credentials boş olamaz.' })
|
|
||||||
|
|
||||||
const config = await readConfig()
|
const config = await readConfig()
|
||||||
const db = config.databases.find(d => d.id === db_id)
|
const db = config.databases.find(d => d.id === db_id)
|
||||||
if (!db) return NextResponse.json({ ok: false, error: 'Veritabanı bulunamadı.' })
|
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 {
|
try {
|
||||||
const buffer = await createDbDumpBuffer(db)
|
const buffer = await createDbDumpBuffer(db)
|
||||||
|
|
||||||
if (cloud_type === 'dropbox') {
|
if (type === 'dropbox') {
|
||||||
await uploadToDropbox(credentials.trim(), db.name, filename, buffer)
|
await uploadToDropbox(credentials.trim(), db.name, filename, buffer)
|
||||||
} else if (cloud_type === 'gcs') {
|
} else if (type === 'gcs') {
|
||||||
await uploadToGCS(credentials, gdrive_folder_id, db.name, filename, buffer)
|
await uploadToGCS(credentials, target, db.name, filename, buffer)
|
||||||
} else if (cloud_type === 'gdrive') {
|
} else if (type === 'gdrive') {
|
||||||
await uploadToGoogleDriveOAuth(credentials, filename, buffer, gdrive_folder_id || undefined)
|
await uploadToGoogleDriveOAuth(credentials, db.name, filename, buffer, target || undefined)
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
|
return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
|
`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
|
const sizeStr = buffer.length > 1024 * 1024
|
||||||
? `${(buffer.length / (1024 * 1024)).toFixed(2)} MB`
|
? `${(buffer.length / (1024 * 1024)).toFixed(2)} MB`
|
||||||
: `${(buffer.length / 1024).toFixed(1)} KB`
|
: `${(buffer.length / 1024).toFixed(1)} KB`
|
||||||
|
|
||||||
return NextResponse.json({
|
const pathStr = type === 'gdrive' ? `${db.name}/${filename}` : filename
|
||||||
ok: true,
|
return NextResponse.json({ ok: true, detail: `✓ Yüklendi — ${pathStr} (${sizeStr})` })
|
||||||
detail: `✓ Yedek yüklendi — ${db.name}/${filename} (${sizeStr})`,
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
await pool.query(
|
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(() => {})
|
||||||
`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 })
|
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 }) {
|
function BackupTab({ db }: { db: Db }) {
|
||||||
const [config, setConfig] = useState<any>(null)
|
const [config, setConfig] = useState<any>(null)
|
||||||
const [logs, setLogs] = useState<any[]>([])
|
const [logs, setLogs] = useState<any[]>([])
|
||||||
|
const [connections, setConnections] = useState<{ id: string; name: string; type: string }[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; detail?: string; error?: string } | null>(null)
|
const [testResult, setTestResult] = useState<{ ok: boolean; detail?: string; error?: string } | null>(null)
|
||||||
const [copiedUri, setCopiedUri] = useState(false)
|
const [f, setF] = useState({ schedule: '0 0 * * *', connection_id: '', target_override: '' })
|
||||||
const [redirectUri, setRedirectUri] = useState('')
|
|
||||||
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'gcs', credentials: '', gdrive_folder_id: '' })
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const r = await fetch(`/api/backups?dbId=${db.id}`)
|
const [backupRes, connsRes] = await Promise.all([
|
||||||
if (r.ok) {
|
fetch(`/api/backups?dbId=${db.id}`),
|
||||||
const data = await r.json()
|
fetch('/api/cloud-connections'),
|
||||||
|
])
|
||||||
|
if (connsRes.ok) setConnections(await connsRes.json())
|
||||||
|
if (backupRes.ok) {
|
||||||
|
const data = await backupRes.json()
|
||||||
setConfig(data.config)
|
setConfig(data.config)
|
||||||
setLogs(data.logs)
|
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)
|
setLoading(false)
|
||||||
}, [db.id])
|
}, [db.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { load() }, [load])
|
||||||
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])
|
|
||||||
|
|
||||||
// gdrive credentials yardımcıları
|
const selectedConn = connections.find(c => c.id === f.connection_id)
|
||||||
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 saveConfig = async () => {
|
const saveConfig = async () => {
|
||||||
setSaving(true)
|
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()
|
await load()
|
||||||
setSaving(false)
|
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 () => {
|
const testNow = async () => {
|
||||||
setTesting(true); setTestResult(null)
|
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()
|
const result = await r.json()
|
||||||
setTestResult(result)
|
setTestResult(result)
|
||||||
if (result.ok) await load()
|
if (result.ok) await load()
|
||||||
@@ -507,14 +490,11 @@ function BackupTab({ db }: { db: Db }) {
|
|||||||
if (!confirm('Otomatik yedeklemeyi kapat?')) return
|
if (!confirm('Otomatik yedeklemeyi kapat?')) return
|
||||||
await fetch('/api/backups', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id }) })
|
await fetch('/api/backups', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id }) })
|
||||||
setConfig(null)
|
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>
|
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 (
|
return (
|
||||||
<div className="flex gap-6 items-start fade-up">
|
<div className="flex gap-6 items-start fade-up">
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
@@ -539,113 +519,45 @@ function BackupTab({ db }: { db: Db }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
|
|
||||||
{/* Provider seçimi */}
|
{/* Bağlantı seçimi */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Depolama</label>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2">
|
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Cloud Bağlantısı</label>
|
||||||
{[{ id: 'gcs', label: '☁️ Google Cloud Storage' }, { id: 'dropbox', label: '📦 Dropbox' }, { id: 'gdrive', label: '📁 Google Drive' }].map(p => (
|
<a href="/dashboard/connections" className="text-[10px] font-mono text-accent hover:underline">+ Yeni Bağlantı →</a>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── GCS ─────────────────────────────────────────────────────── */}
|
{/* Per-DB hedef override */}
|
||||||
{f.cloud_type === 'gcs' && (
|
{selectedConn && (
|
||||||
<>
|
|
||||||
<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' && (
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Access Token</label>
|
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">
|
||||||
<Input value={f.credentials} onChange={e => { setF(p => ({ ...p, credentials: e.target.value })); setTestResult(null) }} placeholder="sl.xxxxxxxxxxxxxxxx..." />
|
{selectedConn.type === 'gcs' ? 'Bucket Override' : selectedConn.type === 'gdrive' ? 'Klasör ID Override' : 'Hedef Override'} (Opsiyonel)
|
||||||
<div className="text-[10px] text-muted/60 font-mono">dropbox.com/developers → Apps → Create app → Generate token</div>
|
</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>
|
</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 */}
|
{/* Test sonucu */}
|
||||||
{testResult && (
|
{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'}`}>
|
<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 */}
|
{/* Butonlar */}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
{isGdrive && !gdriveFields.connected ? (
|
<Button variant="outline" onClick={testNow} disabled={testing || !f.connection_id} className="gap-1.5">
|
||||||
<Button onClick={connectGDrive} disabled={saving || !gdriveFields.client_id || !gdriveFields.client_secret} className="flex-1 gap-1.5">
|
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
|
||||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
|
{testing ? 'Yedekleniyor...' : 'Şimdi Yedekle'}
|
||||||
{saving ? 'Kaydediliyor...' : 'Google ile Bağlan →'}
|
</Button>
|
||||||
</Button>
|
<Button onClick={saveConfig} disabled={saving || !f.connection_id} className="flex-1">
|
||||||
) : (
|
{saving ? 'Kaydediliyor...' : config ? 'Güncelle' : 'Etkinleştir'}
|
||||||
<>
|
</Button>
|
||||||
<Button variant="outline" onClick={testNow} disabled={testing || !credentialsReady} className="gap-1.5" title="Şimdi yedek al ve yükle">
|
{config && <Button variant="danger" onClick={deleteConfig}>Kapat</Button>}
|
||||||
{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>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
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'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
@@ -11,6 +11,7 @@ const nav = [
|
|||||||
{ href: '/dashboard/analytics', label: 'Analytics', icon: LineChart },
|
{ href: '/dashboard/analytics', label: 'Analytics', icon: LineChart },
|
||||||
{ href: '/dashboard/services', label: 'Servisler', icon: Server },
|
{ href: '/dashboard/services', label: 'Servisler', icon: Server },
|
||||||
{ href: '/dashboard/docker', label: 'Docker', icon: Box },
|
{ href: '/dashboard/docker', label: 'Docker', icon: Box },
|
||||||
|
{ href: '/dashboard/connections', label: 'Bağlantılar', icon: Cloud },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ pool.query(`
|
|||||||
telegram_chat_id TEXT,
|
telegram_chat_id TEXT,
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true
|
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)
|
`).catch(console.error)
|
||||||
export type PingLog = {
|
export type PingLog = {
|
||||||
id: number
|
id: number
|
||||||
@@ -276,6 +286,37 @@ export async function getNotificationSettings(): Promise<NotificationSettings |
|
|||||||
return res.rows[0] ?? null
|
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> {
|
export async function saveNotificationSettings(s: Omit<NotificationSettings, never>): Promise<void> {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO notification_settings (id, webhook_url, telegram_token, telegram_chat_id, enabled)
|
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) ────────────────────────────────
|
// ─── Google Drive — OAuth2 (personal account) ────────────────────────────────
|
||||||
// credentials JSON: { client_id, client_secret, refresh_token }
|
// 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(
|
export async function uploadToGoogleDriveOAuth(
|
||||||
credentialsJsonStr: string,
|
credentialsJsonStr: string,
|
||||||
|
dbName: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
content: Buffer,
|
content: Buffer,
|
||||||
parentFolderId?: string
|
parentFolderId?: string
|
||||||
@@ -169,25 +199,24 @@ export async function uploadToGoogleDriveOAuth(
|
|||||||
const { access_token, error: tokenErr } = await tokenRes.json()
|
const { access_token, error: tokenErr } = await tokenRes.json()
|
||||||
if (tokenErr) throw new Error(`Drive token hatası: ${tokenErr}`)
|
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 boundary = 'vps_panel_backup_boundary'
|
||||||
const meta: Record<string, unknown> = { name: filename }
|
const meta = JSON.stringify({ name: filename, parents: [targetFolderId] })
|
||||||
if (parentFolderId) meta.parents = [parentFolderId]
|
|
||||||
|
|
||||||
const parts = Buffer.concat([
|
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`),
|
Buffer.from(`--${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`),
|
||||||
content,
|
content,
|
||||||
Buffer.from(`\r\n--${boundary}--`),
|
Buffer.from(`\r\n--${boundary}--`),
|
||||||
])
|
])
|
||||||
|
|
||||||
const response = await fetch(
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': `multipart/related; boundary=${boundary}` },
|
||||||
Authorization: `Bearer ${access_token}`,
|
|
||||||
'Content-Type': `multipart/related; boundary=${boundary}`,
|
|
||||||
},
|
|
||||||
body: new Uint8Array(parts),
|
body: new Uint8Array(parts),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+37
-23
@@ -1,5 +1,6 @@
|
|||||||
import * as cron from 'node-cron'
|
import * as cron from 'node-cron'
|
||||||
import pool from './appDb'
|
import pool from './appDb'
|
||||||
|
import { getCloudConnection } from './appDb'
|
||||||
import { readConfig } from './config'
|
import { readConfig } from './config'
|
||||||
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from './backup'
|
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDriveOAuth, uploadToGCS, backupFilename } from './backup'
|
||||||
|
|
||||||
@@ -9,10 +10,34 @@ export function startBackupCron() {
|
|||||||
|
|
||||||
const activeTasks = new Map<string, cron.ScheduledTask>()
|
const activeTasks = new Map<string, cron.ScheduledTask>()
|
||||||
|
|
||||||
export async function reloadBackupCrons() {
|
async function runUpload(row: any, db: { name: string }, buffer: Buffer, filename: string) {
|
||||||
for (const [id, task] of activeTasks.entries()) {
|
// connection_id varsa global bağlantıdan al, yoksa eski inline credentials kullan
|
||||||
task.stop()
|
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()
|
activeTasks.clear()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -24,33 +49,22 @@ export async function reloadBackupCrons() {
|
|||||||
if (!db) continue
|
if (!db) continue
|
||||||
|
|
||||||
const task = cron.schedule(row.schedule, async () => {
|
const task = cron.schedule(row.schedule, async () => {
|
||||||
|
const effectiveType = row.connection_id
|
||||||
|
? (await getCloudConnection(row.connection_id))?.type ?? row.cloud_type
|
||||||
|
: row.cloud_type
|
||||||
|
|
||||||
|
console.log(`[Backup] ${db.name} (${effectiveType}) başlatılıyor...`)
|
||||||
try {
|
try {
|
||||||
console.log(`[Backup] ${db.name} (${row.cloud_type}) başlatılıyor...`)
|
|
||||||
|
|
||||||
const buffer = await createDbDumpBuffer(db)
|
const buffer = await createDbDumpBuffer(db)
|
||||||
const filename = backupFilename(db.name)
|
const filename = effectiveType === 'gdrive' ? backupFilename() : backupFilename(db.name)
|
||||||
let message = 'Success'
|
|
||||||
|
|
||||||
try {
|
await runUpload(row, db, buffer, filename)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
|
`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) {
|
} catch (e: any) {
|
||||||
console.error(`[Backup] Hata (${db.name}):`, e)
|
console.error(`[Backup] Hata (${db.name}):`, e)
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|||||||
Reference in New Issue
Block a user