feat: update vps-panel backup and notification features

This commit is contained in:
mstfyldz
2026-06-05 23:53:57 +03:00
parent 29d03604f9
commit c1f04735d6
22 changed files with 1029 additions and 372 deletions
-115
View File
@@ -1,115 +0,0 @@
{
"sites": [
{
"name": "Ayirs Tech",
"url": "https://ayris.tech",
"interval_min": 1,
"id": "1779887873514-4l00t"
},
{
"name": "Onx Control",
"url": "http://onxcontrol.com",
"interval_min": 1,
"id": "1779887898264-992ox"
}
],
"databases": [
{
"name": "Leyüze Butik Chat",
"host": "65.109.236.58",
"port": 3647,
"database": "postgres",
"username": "postgres",
"password": "4T1FnNSDlhhWT1W5bOp3ti6LKw4AFzCarrfXLo2lUuXP8iEvUFWQ3SZpGgnK4C5X",
"ssl": false,
"color": "#00e5ff",
"id": "1779888243791-oyi2i"
},
{
"name": "AyçaNurTuran",
"host": "65.109.236.58",
"port": 6482,
"database": "postgres",
"username": "postgres",
"password": "P9cIY8Ji1iSXOCRs9q6WbOo5xeXCdzyQjYoQ511Zmq1RY8WHLU9YKBGyjDpJ02sa",
"ssl": false,
"color": "#00e5ff",
"id": "1779888647024-853cg"
},
{
"name": "AyrisApart",
"host": "65.109.236.58",
"port": 9435,
"database": "postgres",
"username": "postgres",
"password": "byCfEjXq0Tflw4K0mZ74qczYoajwcUFJFl5WWNTHoxOirgw1NDyoJMEVtyCJBBbk",
"ssl": false,
"color": "#00e5ff",
"id": "1779889157629-xpe3j"
},
{
"name": "X-Tracker",
"host": "65.109.236.58",
"port": 5433,
"database": "postgres",
"username": "postgres",
"password": "6UuYjVZ13ZKfmgKRIsXiCqNjLriAkvugGG9awYqM4BXo78Sg39JypbyNgV72K0zY",
"ssl": false,
"color": "#00e5ff",
"id": "1779889184466-xsq40"
},
{
"name": "Luna QR Menu",
"host": "65.109.236.58",
"port": 4378,
"database": "postgres",
"username": "postgres",
"password": "Oesb5U5YQQAgIByAZZVn3WeL9hqFME72ReI0nUD5EGC546fUINxuheEaLbvsL3jM",
"ssl": false,
"color": "#00e5ff",
"id": "1779889217515-g9t8s"
},
{
"name": "Mugla Dijital Medya",
"host": "65.109.236.58",
"port": 8392,
"database": "postgres",
"username": "postgres",
"password": "xGhPj4IuE5VocaxUoYAj1dSr2xf6M3hh3c2C6YbnB7ZOeVJLRvmL0mzCbhvf14dh",
"ssl": false,
"color": "#00e5ff",
"id": "1779889251060-zk9yi"
},
{
"name": "App-Admin",
"host": "65.109.236.58",
"port": 7397,
"database": "postgres",
"username": "postgres",
"password": "D7Vk2Pu55kixWc0vTqRXsn2SEHtlbcdZBDavGkirOQM45rsYf2QFhKLBzhggvE6c",
"ssl": false,
"color": "#00e5ff",
"id": "1779889294957-vbql4"
},
{
"name": "VPS Panel",
"host": "65.109.236.58",
"port": 4389,
"database": "postgres",
"username": "postgres",
"password": "8VwBw9pepLzDpJITPies6yUVp9SHHsjgZlEDSyM4L3m7JmnP32ywO28FHw05PTUL",
"ssl": false,
"color": "#00e5ff",
"id": "1779889337654-9bfak"
}
],
"services": [
{
"id": "svc-1",
"name": "Coolify",
"url": "http://localhost:8000",
"icon": "⚙️",
"description": "Deploy yönetimi"
}
]
}
BIN
View File
Binary file not shown.
View File
-7
View File
@@ -8,7 +8,6 @@
"name": "vps-panel",
"version": "0.1.0",
"dependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
@@ -1555,12 +1554,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
-1
View File
@@ -9,7 +9,6 @@
"lint": "eslint"
},
"dependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
+41 -2
View File
@@ -1,13 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import { createToken, COOKIE } from '@/lib/auth'
// In-memory rate limiter: max 10 attempts per IP per 15 minutes
const attempts = new Map<string, { count: number; resetAt: number }>()
const MAX_ATTEMPTS = 10
const WINDOW_MS = 15 * 60 * 1000
function getIp(req: NextRequest) {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'0.0.0.0'
)
}
export async function POST(req: NextRequest) {
const ip = getIp(req)
const now = Date.now()
const entry = attempts.get(ip)
if (entry && now < entry.resetAt) {
if (entry.count >= MAX_ATTEMPTS) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000)
return NextResponse.json(
{ error: `Çok fazla deneme. ${retryAfter} saniye bekle.` },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } }
)
}
entry.count++
} else {
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS })
}
const { password } = await req.json()
if (password !== process.env.PANEL_SECRET)
if (password !== process.env.PANEL_SECRET) {
return NextResponse.json({ error: 'Yanlış şifre' }, { status: 401 })
}
// Başarılı giriş — sayacı sıfırla
attempts.delete(ip)
const token = await createToken()
const res = NextResponse.json({ ok: true })
res.cookies.set(COOKIE, token, { httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, path: '/' })
res.cookies.set(COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return res
}
+14 -4
View File
@@ -1,10 +1,14 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/appDb'
import { generateId } from '@/lib/config'
import { reloadBackupCrons } from '@/lib/cronWorker'
import { requireAuth } from '@/lib/auth'
// GET configs and logs for a dbId
export async function GET(req: Request) {
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { searchParams } = new URL(req.url)
const dbId = searchParams.get('dbId')
if (!dbId) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
@@ -23,7 +27,10 @@ export async function GET(req: Request) {
}
// Create or update a backup config
export async function POST(req: Request) {
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
try {
const body = await req.json()
const { db_id, schedule, cloud_type, credentials, gdrive_folder_id } = body
@@ -57,7 +64,10 @@ export async function POST(req: Request) {
}
// Delete backup config
export async function DELETE(req: Request) {
export async function DELETE(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
try {
const body = await req.json()
const { db_id } = body
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { readConfig } from '@/lib/config'
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDrive, backupFilename, testGoogleDriveCredentials } from '@/lib/backup'
import pool from '@/lib/appDb'
// POST /api/backups/test
// { db_id, cloud_type, credentials, gdrive_folder_id? }
// → Gerçek yedek alır, buluta yükler, loglar
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.' })
}
// DB bilgisini al
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()
try {
// 1. Dump al
const buffer = await createDbDumpBuffer(db)
// 2. Buluta yükle
if (cloud_type === 'dropbox') {
await uploadToDropbox(credentials.trim(), db.name, filename, buffer)
} else if (cloud_type === 'gdrive') {
await uploadToGoogleDrive(credentials, db.name, filename, buffer, gdrive_folder_id || undefined)
} else {
return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
}
// 3. Logla
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db_id, 'success', `Manuel test yedek — ${cloud_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 alındı ve yüklendi — ${db.name}/${filename} (${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(() => {})
return NextResponse.json({ ok: false, error: e.message })
}
}
+18 -8
View File
@@ -1,10 +1,14 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { readConfig } from '@/lib/config'
import { createDbDumpBuffer } from '@/lib/backup'
import { requireAuth } from '@/lib/auth'
import pool from '@/lib/appDb'
// GET /api/db/backup?dbId=...
export async function GET(req: Request) {
export async function GET(req: NextRequest) {
const authErr = await requireAuth(req)
if (authErr) return authErr
const { searchParams } = new URL(req.url)
const dbId = searchParams.get('dbId')
if (!dbId) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
@@ -18,16 +22,22 @@ export async function GET(req: Request) {
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql`
await pool.query(`INSERT INTO backup_logs (db_id, status, error, size, target) VALUES ($1, $2, $3, $4, $5)`, [dbId, 'success', null, buffer.length, 'manuel indirme'])
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[dbId, 'success', 'Manuel indirme', buffer.length]
)
return new NextResponse(buffer.toString('utf-8'), {
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/sql',
'Content-Disposition': `attachment; filename="${filename}"`
}
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
} catch (e: any) {
await pool.query(`INSERT INTO backup_logs (db_id, status, error, size, target) VALUES ($1, $2, $3, $4, $5)`, [dbId, 'error', e.message, 0, 'manuel indirme'])
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[dbId, 'error', e.message, 0]
)
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+94 -24
View File
@@ -1,42 +1,112 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { readConfig } from '@/lib/config'
import { requireAuth } from '@/lib/auth'
export const dynamic = 'force-dynamic'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const hostId = searchParams.get('hostId')
async function getDockerHost(hostId: string) {
const config = await readConfig()
return config.docker_hosts?.find(h => h.id === hostId) ?? null
}
if (!hostId) {
return NextResponse.json({ error: 'hostId is required' }, { status: 400 })
function normalizeUrl(url: string) {
return url.endsWith('/') ? url.slice(0, -1) : url
}
// Docker log stream parser — 8-byte frame header: [stream(1), 0, 0, 0, size(4-BE)]
function parseDockerLogs(buffer: Buffer): string {
const lines: string[] = []
let offset = 0
while (offset + 8 <= buffer.length) {
const size = buffer.readUInt32BE(offset + 4)
if (size === 0) { offset += 8; continue }
if (offset + 8 + size > buffer.length) break
lines.push(buffer.slice(offset + 8, offset + 8 + size).toString('utf-8').replace(/\n$/, ''))
offset += 8 + size
}
return lines.join('\n')
}
// GET /api/docker?hostId=xxx → list containers
// GET /api/docker?hostId=xxx&containerId=xxx&action=logs → container logs
export async function GET(req: NextRequest) {
const authErr = await requireAuth(req)
if (authErr) return authErr
const { searchParams } = req.nextUrl
const hostId = searchParams.get('hostId')
const containerId = searchParams.get('containerId')
const action = searchParams.get('action')
if (!hostId) return NextResponse.json({ error: 'hostId gerekli' }, { status: 400 })
const host = await getDockerHost(hostId)
if (!host) return NextResponse.json({ error: 'Docker host bulunamadı' }, { status: 404 })
const base = normalizeUrl(host.url)
try {
const config = await readConfig()
const host = config.docker_hosts?.find(h => h.id === hostId)
if (!host) {
return NextResponse.json({ error: 'Docker host not found' }, { status: 404 })
if (action === 'logs' && containerId) {
const res = await fetch(`${base}/containers/${containerId}/logs?tail=200&stdout=1&stderr=1`, {
headers: { Accept: 'application/octet-stream' },
cache: 'no-store',
})
if (!res.ok) return NextResponse.json({ error: `Docker API Error: ${res.statusText}` }, { status: res.status })
const buf = Buffer.from(await res.arrayBuffer())
return NextResponse.json({ logs: parseDockerLogs(buf) })
}
let baseUrl = host.url
if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1)
// Docker Engine API: list all containers
const res = await fetch(`${baseUrl}/containers/json?all=1`, {
headers: { 'Accept': 'application/json' },
cache: 'no-store'
// List containers
const res = await fetch(`${base}/containers/json?all=1`, {
headers: { Accept: 'application/json' },
cache: 'no-store',
})
if (!res.ok) {
const text = await res.text()
return NextResponse.json({ error: `Docker API Error: ${res.statusText}`, details: text }, { status: res.status })
}
return NextResponse.json(await res.json())
} catch (e: any) {
return NextResponse.json({ error: 'Bağlantı hatası', details: e.message }, { status: 500 })
}
}
const containers = await res.json()
return NextResponse.json(containers)
} catch (error: any) {
console.error('Docker API Error:', error)
return NextResponse.json({ error: 'Bağlantı hatası', details: error.message }, { status: 500 })
// POST /api/docker { hostId, containerId, action: 'start'|'stop'|'restart' }
export async function POST(req: NextRequest) {
const authErr = await requireAuth(req)
if (authErr) return authErr
const body = await req.json()
const { hostId, containerId, action } = body
if (!hostId || !containerId || !action) {
return NextResponse.json({ error: 'hostId, containerId ve action gerekli' }, { status: 400 })
}
const validActions = ['start', 'stop', 'restart']
if (!validActions.includes(action)) {
return NextResponse.json({ error: 'Geçersiz action' }, { status: 400 })
}
const host = await getDockerHost(hostId)
if (!host) return NextResponse.json({ error: 'Docker host bulunamadı' }, { status: 404 })
const base = normalizeUrl(host.url)
try {
const res = await fetch(`${base}/containers/${containerId}/${action}`, {
method: 'POST',
cache: 'no-store',
})
// 204 = success (already in desired state or action completed)
if (res.status === 204 || res.status === 200) {
return NextResponse.json({ ok: true })
}
const text = await res.text()
return NextResponse.json({ error: `Docker API Error: ${res.statusText}`, details: text }, { status: res.status })
} catch (e: any) {
return NextResponse.json({ error: 'Bağlantı hatası', details: e.message }, { status: 500 })
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getNotificationSettings, saveNotificationSettings } from '@/lib/appDb'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const settings = await getNotificationSettings()
return NextResponse.json(settings ?? { webhook_url: '', telegram_token: '', telegram_chat_id: '', enabled: true })
}
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const body = await req.json()
await saveNotificationSettings({
webhook_url: body.webhook_url || null,
telegram_token: body.telegram_token || null,
telegram_chat_id: body.telegram_chat_id || null,
enabled: body.enabled ?? true,
})
return NextResponse.json({ ok: true })
}
+265 -71
View File
@@ -4,7 +4,7 @@ import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/com
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AnimatedTabs } from '@/components/ui/animated-tabs'
import { Plus, X, Database as DatabaseIcon, Download, Play, Table as TableIcon, BarChart2, HardDrive, ShieldAlert, CheckCircle2 } from 'lucide-react'
import { Plus, X, Database as DatabaseIcon, Download, Play, Table as TableIcon, BarChart2, HardDrive, ShieldAlert, CheckCircle2, Loader2, FlaskConical, Copy } from 'lucide-react'
type Db = { id: string; name: string; host: string; port: number; database: string; username: string; color: string; ssl: boolean; db_type: 'postgres' | 'mysql' }
type Table = { table_name: string; row_count: number; size: string }
@@ -250,10 +250,36 @@ export default function DatabasesPage() {
)}
{/* Stats detail */}
{tab === 'stats' && stats && (
<Card className="p-6 fade-up">
<pre className="font-mono text-xs text-muted/80 whitespace-pre-wrap leading-relaxed">{JSON.stringify(stats, null, 2)}</pre>
</Card>
{tab === 'stats' && (
<div className="fade-up space-y-4">
{!stats ? (
<div className="text-muted font-mono text-sm animate-pulse">Yükleniyor...</div>
) : (
<>
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Veritabanı Boyutu', value: String(stats.size), desc: 'Toplam disk kullanımı', color: 'text-accent' },
{ label: 'Aktif Bağlantı', value: String(stats.active_connections), desc: 'Şu an açık bağlantı sayısı', color: 'text-success' },
{ label: 'Tablo Sayısı', value: String(stats.table_count), desc: 'Public schema tabloları', color: 'text-purple-400' },
].map(s => (
<Card key={s.label} className="bg-surface/30">
<CardContent className="p-6">
<div className="text-[10px] text-muted font-mono uppercase tracking-wider mb-2">{s.label}</div>
<div className={`text-3xl font-extrabold tracking-tight mb-1 ${s.color}`}>{s.value}</div>
<div className="text-[11px] text-muted/60 font-mono">{s.desc}</div>
</CardContent>
</Card>
))}
</div>
<Card className="bg-surface/30">
<CardContent className="p-5">
<div className="text-[10px] text-muted font-mono uppercase tracking-wider mb-3">Ham Veri</div>
<pre className="font-mono text-xs text-muted/70 whitespace-pre-wrap leading-relaxed">{JSON.stringify(stats, null, 2)}</pre>
</CardContent>
</Card>
</>
)}
</div>
)}
{/* Backups */}
@@ -399,12 +425,26 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
)
}
const CRON_PRESETS = [
{ label: 'Her gece 00:00', value: '0 0 * * *' },
{ label: 'Her 6 saatte', value: '0 */6 * * *' },
{ label: 'Her 12 saatte', value: '0 */12 * * *' },
{ label: 'Her Pazartesi', value: '0 0 * * 1' },
]
function extractGdriveEmail(json: string): string | null {
try { return JSON.parse(json)?.client_email ?? null } catch { return null }
}
function BackupTab({ db }: { db: Db }) {
const [config, setConfig] = useState<any>(null)
const [logs, setLogs] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '', gdrive_folder_id: '' })
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; detail?: string; error?: string } | null>(null)
const [copied, setCopied] = useState(false)
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'gdrive', credentials: '', gdrive_folder_id: '' })
const load = useCallback(async () => {
setLoading(true)
@@ -427,105 +467,259 @@ function BackupTab({ db }: { db: Db }) {
await fetch('/api/backups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id, ...f })
body: JSON.stringify({ db_id: db.id, ...f }),
})
await load()
setSaving(false)
}
const testCredentials = 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 result = await r.json()
setTestResult(result)
if (result.ok) await load() // log listesini güncelle
setTesting(false)
}
const deleteConfig = async () => {
if (!confirm('Otomatik yedeklemeyi kapat?')) return
await fetch('/api/backups', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db_id: db.id })
})
await fetch('/api/backups', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db_id: db.id }) })
setConfig(null)
setF({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '', gdrive_folder_id: '' })
setF({ schedule: '0 0 * * *', cloud_type: 'gdrive', credentials: '', gdrive_folder_id: '' })
}
const serviceEmail = f.cloud_type === 'gdrive' ? extractGdriveEmail(f.credentials) : null
const copyEmail = () => {
if (serviceEmail) { navigator.clipboard.writeText(serviceEmail); setCopied(true); setTimeout(() => setCopied(false), 2000) }
}
if (loading) return <div className="text-muted text-sm font-mono animate-pulse">Yükleniyor...</div>
return (
<div className="flex gap-6 items-start fade-up">
<Card className="flex-1">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div>
<CardTitle className="text-lg">Otomatik Yedekleme</CardTitle>
<CardDescription>Düzenli veritabanı yedekleri alın ve buluta gönderin.</CardDescription>
</div>
<a href={`/api/db/backup?dbId=${db.id}`} download className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-xs font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 border border-border bg-transparent hover:bg-surface-2 text-text h-8 px-3">
<Download className="w-3.5 h-3.5 mr-1.5" />
Hemen İndir
</a>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Cron Schedule (node-cron)</label>
<Input value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" />
</div>
<div className="flex-1 space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Cloud Sağlayıcı</label>
<select
className="flex h-9 w-full rounded-md border border-border bg-black/30 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent font-mono appearance-none"
value={f.cloud_type}
onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}
{/* Manuel indirme */}
<Card className="border-border/50">
<CardContent className="p-4 flex items-center justify-between">
<div>
<div className="text-sm font-semibold mb-0.5">Manuel Yedek Al</div>
<div className="text-[11px] text-muted font-mono">SQL dump olarak indir</div>
</div>
<a
href={`/api/db/backup?dbId=${db.id}`}
download
className="inline-flex items-center justify-center gap-1.5 rounded-md text-xs font-medium border border-border bg-transparent hover:bg-surface-2 text-text h-8 px-3 transition-colors"
>
<option value="dropbox">Dropbox</option>
<option value="gdrive">Google Drive</option>
</select>
</div>
<Download className="w-3.5 h-3.5" /> İndir
</a>
</CardContent>
</Card>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Credentials (JSON veya Token)</label>
<textarea
className="flex w-full rounded-md border border-border bg-black/30 px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent font-mono resize-y min-h-[100px]"
value={f.credentials}
onChange={e => setF(p => ({ ...p, credentials: e.target.value }))}
placeholder={f.cloud_type === 'dropbox' ? 'Dropbox Access Token buraya...' : 'Google Service Account JSON buraya...'}
/>
</div>
{/* Cloud ayarları */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base">Otomatik Yedekleme</CardTitle>
<CardDescription>Zamanlı yedek alıp buluta yükle.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{f.cloud_type === 'gdrive' && (
{/* Provider */}
<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="Örn: 1A2b3C4d5E6f7G8h9I0j..." />
<div className="text-[10px] text-muted/80 font-mono pt-1">
Service account e-posta adresini, kendi Drive'ınızda açtığınız bir klasöre "Düzenleyici" olarak ekleyin ve klasörün ID'sini buraya yapıştırın.
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Depolama</label>
<div className="flex gap-2">
{[
{ id: 'gdrive', label: '📁 Google Drive' },
{ id: 'dropbox', label: '📦 Dropbox' },
].map(p => (
<button
key={p.id}
onClick={() => { setF(prev => ({ ...prev, cloud_type: p.id })); setTestResult(null) }}
className={`flex-1 py-2 rounded-lg border text-sm 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>
)}
<div className="flex gap-3 pt-2">
<Button onClick={saveConfig} disabled={saving} className="flex-1">
{saving ? 'Kaydediliyor...' : 'Kaydet'}
</Button>
{config && (
<Button variant="danger" onClick={deleteConfig}>Kapat</Button>
{/* Google Drive kurulum rehberi */}
{f.cloud_type === 'gdrive' && (
<div className="rounded-xl border border-border/50 bg-black/20 p-4 space-y-3">
<div className="text-[10px] font-mono text-muted uppercase tracking-wider">Google Drive Kurulumu</div>
{[
{ n: 1, text: 'console.cloud.google.com → Yeni proje oluştur (veya mevcut)' },
{ n: 2, text: '"APIs & Services" → "Enable APIs" → "Google Drive API" aç' },
{ n: 3, text: '"Credentials" → "Service Accounts" → Yeni hesap oluştur' },
{ n: 4, text: 'Hesaba tıkla → "Keys" → "Add Key" → "JSON" — dosyayı indir' },
{ n: 5, text: 'Google Drive\'da yeni klasör aç → Sağ tık → Paylaş' },
{ n: 6, text: 'Aşağıdaki e-postayı "Düzenleyici" olarak ekle ve klasör ID\'sini kopyala' },
].map(s => (
<div key={s.n} className="flex gap-3 text-[11px] font-mono leading-relaxed">
<span className="w-5 h-5 rounded-full bg-accent/10 border border-accent/20 text-accent flex items-center justify-center text-[10px] shrink-0 mt-0.5">{s.n}</span>
<span className="text-muted/80">{s.text}</span>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Credentials */}
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">
{f.cloud_type === 'gdrive' ? 'Service Account JSON' : 'Dropbox Access Token'}
</label>
<textarea
className="flex w-full rounded-md border border-border bg-black/30 px-3 py-2 text-xs shadow-sm transition-colors placeholder:text-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent font-mono resize-y min-h-[120px]"
value={f.credentials}
onChange={e => { setF(p => ({ ...p, credentials: e.target.value })); setTestResult(null) }}
placeholder={f.cloud_type === 'gdrive'
? '{\n "type": "service_account",\n "project_id": "...",\n "private_key": "-----BEGIN RSA PRIVATE KEY-----\\n...",\n "client_email": "xxx@project.iam.gserviceaccount.com",\n ...\n}'
: 'sl.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'}
/>
</div>
{/* Extracted email banner */}
{serviceEmail && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-accent/20 bg-accent/5 px-4 py-3">
<div>
<div className="text-[10px] text-muted font-mono uppercase tracking-wider mb-1">Bu e-postayı Drive klasörünüzle paylaşın</div>
<div className="text-sm font-mono text-accent">{serviceEmail}</div>
</div>
<button
onClick={copyEmail}
className="p-1.5 rounded text-muted hover:text-accent transition-colors shrink-0"
title="Kopyala"
>
{copied ? <CheckCircle2 className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
{/* Klasör ID */}
{f.cloud_type === 'gdrive' && (
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Klasör ID</label>
<Input
value={f.gdrive_folder_id}
onChange={e => { setF(p => ({ ...p, gdrive_folder_id: e.target.value })); setTestResult(null) }}
placeholder="1A2b3C4d5E6f7G8h9I0jKlMnOpQrStUvWx"
/>
<div className="text-[10px] text-muted/60 font-mono">
Drive klasörünü açınca URL'deki <span className="text-muted">/folders/</span> sonrasındaki kısım
</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'
}`}>
{testResult.ok
? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
: <ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />}
<span>{testResult.ok ? testResult.detail : testResult.error}</span>
</div>
)}
{/* Schedule */}
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Zamanlama</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{CRON_PRESETS.map(p => (
<button
key={p.value}
onClick={() => setF(prev => ({ ...prev, schedule: p.value }))}
className={`text-[10px] font-mono px-2.5 py-1 rounded-md border transition-all ${
f.schedule === p.value
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border/50 text-muted hover:text-text hover:border-border'
}`}
>
{p.label}
</button>
))}
</div>
<Input value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" className="font-mono text-sm" />
<div className="text-[10px] text-muted/60 font-mono">cron format: dakika saat gün ay haftaGünü</div>
</div>
{/* Butonlar */}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
onClick={testCredentials}
disabled={testing || !f.credentials}
className="gap-1.5"
title="Şimdi yedek al ve buluta 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 || !f.credentials} className="flex-1">
{saving ? 'Kaydediliyor...' : config ? 'Güncelle' : 'Etkinleştir'}
</Button>
{config && (
<Button variant="danger" onClick={deleteConfig}>Kapat</Button>
)}
</div>
</CardContent>
</Card>
</div>
{/* Log paneli */}
<Card className="w-80 shrink-0">
<CardHeader className="pb-4">
<CardTitle className="text-sm">Geçmiş İşlemler</CardTitle>
<CardTitle className="text-sm">Yedekleme Geçmişi</CardTitle>
</CardHeader>
<CardContent>
{logs.length === 0 ? <div className="text-muted text-xs font-mono">Kayıt yok</div> : (
{logs.length === 0 ? (
<div className="text-muted text-xs font-mono">Henüz kayıt yok</div>
) : (
<div className="flex flex-col gap-2.5">
{logs.map((log: any) => (
<div key={log.id} className={`p-3 rounded-lg border bg-surface/50 ${log.status === 'success' ? 'border-l-2 border-l-success border-border/50' : 'border-l-2 border-l-destructive border-border/50'}`}>
<div
key={log.id}
className={`p-3 rounded-lg border bg-surface/50 ${
log.status === 'success'
? 'border-l-2 border-l-success border-border/50'
: 'border-l-2 border-l-destructive border-border/50'
}`}
>
<div className="flex justify-between items-center mb-1">
<span className={`text-[10px] font-bold uppercase tracking-wider ${log.status === 'success' ? 'text-success' : 'text-destructive flex items-center gap-1'}`}>
{log.status === 'success' ? <CheckCircle2 className="w-3 h-3 inline mr-1"/> : <ShieldAlert className="w-3 h-3 inline mr-1"/>}
<span className={`text-[10px] font-bold uppercase tracking-wider flex items-center gap-1 ${log.status === 'success' ? 'text-success' : 'text-destructive'}`}>
{log.status === 'success'
? <CheckCircle2 className="w-3 h-3" />
: <ShieldAlert className="w-3 h-3" />}
{log.status}
</span>
<span className="text-[10px] text-muted font-mono">{new Date(log.ts * 1000).toLocaleString()}</span>
<span className="text-[10px] text-muted font-mono">{new Date(log.ts * 1000).toLocaleString('tr-TR')}</span>
</div>
{log.status === 'success' && <div className="text-xs text-muted/80 font-mono mt-2">Boyut: {(log.file_size / 1024).toFixed(2)} KB</div>}
{log.status !== 'success' && <div className="text-[11px] text-destructive/90 font-mono mt-1.5 leading-tight">{log.message}</div>}
{log.status === 'success' && log.file_size > 0 && (
<div className="text-[10px] text-muted/70 font-mono mt-1.5">
{log.file_size > 1024 * 1024
? `${(log.file_size / (1024 * 1024)).toFixed(2)} MB`
: `${(log.file_size / 1024).toFixed(1)} KB`}
</div>
)}
{log.status !== 'success' && log.message && (
<div className="text-[10px] text-destructive/80 font-mono mt-1.5 leading-tight break-all">{log.message}</div>
)}
</div>
))}
</div>
+142 -21
View File
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, X, Box, ShieldAlert, CheckCircle2, Play, Square, RotateCw } from 'lucide-react'
import { Plus, X, Box, ShieldAlert, RotateCw, Play, Square, ScrollText } from 'lucide-react'
type DockerHost = { id: string; name: string; url: string }
type Container = {
@@ -20,8 +20,10 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
const [selected, setSelected] = useState<DockerHost | null>(null)
const [containers, setContainers] = useState<Container[]>([])
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [logsModal, setLogsModal] = useState<{ containerId: string; name: string } | null>(null)
const loadHosts = useCallback(async () => {
const r = await fetch('/api/config')
@@ -29,7 +31,7 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
if (cfg.docker_hosts) setHosts(cfg.docker_hosts)
}, [])
const select = async (host: DockerHost) => {
const select = useCallback(async (host: DockerHost) => {
setSelected(host)
setContainers([])
setError('')
@@ -37,15 +39,35 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
try {
const r = await fetch(`/api/docker?hostId=${host.id}`)
const data = await r.json()
if (r.ok) {
setContainers(data)
} else {
setError(data.error || 'Bağlantı hatası')
}
if (r.ok) setContainers(data)
else setError(data.error || 'Bağlantı hatası')
} catch (e: any) {
setError(e.message)
}
setLoading(false)
}, [])
const containerAction = async (containerId: string, action: 'start' | 'stop' | 'restart') => {
if (!selected) return
setActionLoading(containerId + action)
try {
const r = await fetch('/api/docker', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostId: selected.id, containerId, action }),
})
if (!r.ok) {
const d = await r.json()
setError(d.error || 'Aksiyon başarısız')
} else {
// Kısa bekleyip listeyi yenile
await new Promise(res => setTimeout(res, 800))
await select(selected)
}
} catch (e: any) {
setError(e.message)
}
setActionLoading(null)
}
const deleteHost = async (id: string) => {
@@ -122,11 +144,10 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
{error && (
<div className="p-4 text-destructive font-mono text-sm bg-destructive/5 flex items-center gap-2 border border-destructive/20 rounded-xl mb-6">
<ShieldAlert className="w-4 h-4"/> {error}
<ShieldAlert className="w-4 h-4" /> {error}
</div>
)}
{/* Containers */}
{!error && (
<Card className="overflow-hidden">
{loading && containers.length === 0
@@ -137,7 +158,7 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
<table className="w-full text-left border-collapse">
<thead>
<tr>
{['İsim', 'İmaj', 'Durum', 'Portlar'].map(h => (
{['İsim', 'İmaj', 'Durum', 'Portlar', 'İşlem'].map(h => (
<th key={h} className="px-6 py-3 text-[10px] font-mono text-muted uppercase tracking-wider border-b border-border bg-black/20">
{h}
</th>
@@ -148,32 +169,70 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
{containers.map((c, i) => {
const name = c.Names?.[0]?.replace(/^\//, '') || 'Bilinmiyor'
const isRunning = c.State === 'running'
const isActing = actionLoading?.startsWith(c.Id)
return (
<tr
key={c.Id}
className="group hover:bg-white/[0.02] transition-colors"
>
<td className={`px-6 py-4 text-sm font-semibold text-accent ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
<tr key={c.Id} className="group hover:bg-white/[0.02] transition-colors">
<td className={`px-6 py-4 text-sm font-semibold text-accent ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}>
{name}
</td>
<td className={`px-6 py-4 text-xs font-mono text-muted max-w-[200px] truncate ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
<td className={`px-6 py-4 text-xs font-mono text-muted max-w-[200px] truncate ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}>
{c.Image}
</td>
<td className={`px-6 py-4 text-xs font-mono ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
<td className={`px-6 py-4 text-xs font-mono ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isRunning ? 'bg-success shadow-[0_0_8px_var(--color-success)]' : 'bg-muted'}`} />
<span className={isRunning ? 'text-success' : 'text-muted'}>{c.Status}</span>
</div>
</td>
<td className={`px-6 py-4 text-[10px] font-mono text-muted ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
<td className={`px-6 py-4 text-[10px] font-mono text-muted ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}>
<div className="flex flex-wrap gap-1">
{c.Ports?.map((p, idx) => (
<span key={idx} className="bg-surface-2 px-1.5 py-0.5 rounded border border-border/50">
{p.PublicPort ? `${p.PublicPort}->${p.PrivatePort}` : p.PrivatePort}/{p.Type}
{p.PublicPort ? `${p.PublicPort}${p.PrivatePort}` : p.PrivatePort}/{p.Type}
</span>
))}
</div>
</td>
<td className={`px-4 py-3 ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}>
<div className="flex items-center gap-1">
{isRunning ? (
<>
<button
onClick={() => containerAction(c.Id, 'stop')}
disabled={!!isActing}
title="Durdur"
className="p-1.5 rounded-lg text-muted hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40"
>
<Square className="w-3.5 h-3.5" />
</button>
<button
onClick={() => containerAction(c.Id, 'restart')}
disabled={!!isActing}
title="Yeniden Başlat"
className="p-1.5 rounded-lg text-muted hover:text-warning hover:bg-warning/10 transition-colors disabled:opacity-40"
>
<RotateCw className={`w-3.5 h-3.5 ${actionLoading === c.Id + 'restart' ? 'animate-spin' : ''}`} />
</button>
</>
) : (
<button
onClick={() => containerAction(c.Id, 'start')}
disabled={!!isActing}
title="Başlat"
className="p-1.5 rounded-lg text-muted hover:text-success hover:bg-success/10 transition-colors disabled:opacity-40"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => setLogsModal({ containerId: c.Id, name })}
title="Logları Gör"
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-accent/10 transition-colors"
>
<ScrollText className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
})}
@@ -188,6 +247,68 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
</div>
{showAdd && <AddHostModal onClose={() => setShowAdd(false)} onAdded={() => { loadHosts(); setShowAdd(false) }} />}
{logsModal && selected && (
<LogsModal
hostId={selected.id}
containerId={logsModal.containerId}
name={logsModal.name}
onClose={() => setLogsModal(null)}
/>
)}
</div>
)
}
function LogsModal({ hostId, containerId, name, onClose }: {
hostId: string
containerId: string
name: string
onClose: () => void
}) {
const [logs, setLogs] = useState<string>('Yükleniyor...')
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await fetch(`/api/docker?hostId=${hostId}&containerId=${containerId}&action=logs`)
const d = await r.json()
setLogs(d.logs || '(log yok)')
} catch {
setLogs('Log alınamadı.')
}
setLoading(false)
}, [hostId, containerId])
useEffect(() => { load() }, [load])
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<div
className="bg-surface border border-border rounded-2xl w-full max-w-4xl max-h-[80vh] flex flex-col shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<ScrollText className="w-4 h-4 text-accent" />
<span className="font-semibold text-sm">{name}</span>
<span className="text-[10px] text-muted font-mono bg-surface-2 px-2 py-0.5 rounded">son 200 satır</span>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={load} disabled={loading} className="h-7 text-xs">
<RotateCw className={`w-3 h-3 mr-1 ${loading ? 'animate-spin' : ''}`} /> Yenile
</Button>
<button onClick={onClose} className="text-muted hover:text-text transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 bg-black/40 rounded-b-2xl">
<pre className="text-[11px] font-mono text-text/80 whitespace-pre-wrap leading-relaxed">
{logs}
</pre>
</div>
</div>
</div>
)
}
@@ -202,7 +323,7 @@ function AddHostModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =
try {
const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', item: f }) })
if (res.ok) { onAdded() } else { setError((await res.json()).error ?? 'Hata'); setLoading(false) }
} catch (e) {
} catch {
setError('Bağlantı eklenemedi.')
setLoading(false)
}
@@ -225,7 +346,7 @@ function AddHostModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =
<p className="text-[10px] text-muted font-mono mt-1">Docker Daemon'ın TCP üzerinden erişilebilir olduğundan emin olun.</p>
</div>
{error && <div className="text-destructive text-xs font-mono bg-destructive/10 p-2 rounded-md border border-destructive/20 mt-2 flex items-center gap-2"><ShieldAlert className="w-3.5 h-3.5"/>{error}</div>}
{error && <div className="text-destructive text-xs font-mono bg-destructive/10 p-2 rounded-md border border-destructive/20 mt-2 flex items-center gap-2"><ShieldAlert className="w-3.5 h-3.5" />{error}</div>}
<div className="flex gap-3 pt-4 border-t border-border/50 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button>
+41 -21
View File
@@ -6,6 +6,7 @@ type Service = { id: string; name: string; url: string; icon: string; descriptio
export default function ServicesPage() {
const [services, setServices] = useState<Service[]>([])
const [showAdd, setShowAdd] = useState(false)
const [editingService, setEditingService] = useState<Service | null>(null)
const load = useCallback(async () => {
const r = await fetch('/api/config')
@@ -44,9 +45,16 @@ export default function ServicesPage() {
<div key={svc.id} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, padding: '20px', position: 'relative', transition: 'border-color .2s' }}
onMouseOver={e => (e.currentTarget.style.borderColor = 'rgba(255,255,255,.15)')}
onMouseOut={e => (e.currentTarget.style.borderColor = 'var(--border)')}>
<button onClick={() => del(svc.id)} style={{ position: 'absolute', top: 12, right: 12, background: 'none', border: 'none', color: 'var(--muted)', fontSize: 12, cursor: 'pointer', opacity: .5 }}
onMouseOver={e => (e.currentTarget.style.opacity = '1')}
onMouseOut={e => (e.currentTarget.style.opacity = '.5')}></button>
<div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: 4 }}>
<button onClick={() => setEditingService(svc)}
style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 12, cursor: 'pointer', opacity: .5, padding: '2px 4px' }}
onMouseOver={e => (e.currentTarget.style.opacity = '1')}
onMouseOut={e => (e.currentTarget.style.opacity = '.5')}></button>
<button onClick={() => del(svc.id)}
style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 12, cursor: 'pointer', opacity: .5, padding: '2px 4px' }}
onMouseOver={e => (e.currentTarget.style.opacity = '1')}
onMouseOut={e => (e.currentTarget.style.opacity = '.5')}></button>
</div>
<div style={{ fontSize: 28, marginBottom: 10 }}>{svc.icon}</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 4 }}>{svc.name}</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 14, lineHeight: 1.4 }}>{svc.description}</div>
@@ -60,23 +68,34 @@ export default function ServicesPage() {
)
}
{showAdd && <AddServiceModal onClose={() => setShowAdd(false)} onAdded={() => { load(); setShowAdd(false) }} />}
{showAdd && <ServiceModal onClose={() => setShowAdd(false)} onAdded={() => { load(); setShowAdd(false) }} />}
{editingService && (
<ServiceModal
service={editingService}
onClose={() => setEditingService(null)}
onAdded={() => { load(); setEditingService(null) }}
/>
)}
</div>
)
}
function AddServiceModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
const [f, setF] = useState({ name: '', url: 'http://', icon: '🔧', description: '' })
function ServiceModal({ service, onClose, onAdded }: { service?: Service; onClose: () => void; onAdded: () => void }) {
const [f, setF] = useState({ name: service?.name || '', url: service?.url || 'http://', icon: service?.icon || '🔧', description: service?.description || '' })
const [loading, setLoading] = useState(false)
const submit = async () => {
setLoading(true)
await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'service', item: f }) })
if (service) {
await fetch('/api/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'service', id: service.id, item: f }) })
} else {
await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'service', item: f }) })
}
setLoading(false)
onAdded()
}
const inp: React.CSSProperties = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none' }
const inp: React.CSSProperties = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box' }
const lbl: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }
const presets = [
@@ -89,20 +108,21 @@ function AddServiceModal({ onClose, onAdded }: { onClose: () => void; onAdded: (
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 420 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>Servis Ekle</div>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>{service ? 'Servis Düzenle' : 'Servis Ekle'}</div>
{/* Presets */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Hızlı Ekle</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{presets.map(p => (
<button key={p.name} onClick={() => setF(p)}
style={{ background: 'rgba(255,255,255,.04)', border: '1px solid var(--border)', borderRadius: 6, padding: '5px 10px', color: 'var(--text)', fontSize: 12, cursor: 'pointer' }}>
{p.icon} {p.name}
</button>
))}
{!service && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Hızlı Ekle</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{presets.map(p => (
<button key={p.name} onClick={() => setF(p)}
style={{ background: 'rgba(255,255,255,.04)', border: '1px solid var(--border)', borderRadius: 6, padding: '5px 10px', color: 'var(--text)', fontSize: 12, cursor: 'pointer' }}>
{p.icon} {p.name}
</button>
))}
</div>
</div>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '50px 1fr', gap: 8 }}>
@@ -116,7 +136,7 @@ function AddServiceModal({ onClose, onAdded }: { onClose: () => void; onAdded: (
<div style={{ display: 'flex', gap: 10, marginTop: 22 }}>
<button onClick={onClose} style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>İptal</button>
<button onClick={submit} disabled={loading || !f.name} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
{loading ? '...' : 'Ekle'}
{loading ? '...' : service ? 'Güncelle' : 'Ekle'}
</button>
</div>
</div>
+78 -3
View File
@@ -1,9 +1,11 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Bell } from 'lucide-react'
type Site = { id: string; name: string; url: string; interval_min: number }
type Log = { status: string; ms: number | null; code: number | null; error: string | null; ts: number }
type SiteData = { logs: Log[]; uptime24: number; uptime7d: number; last: Log | null }
type NotifSettings = { webhook_url: string; telegram_token: string; telegram_chat_id: string; enabled: boolean }
const sc = (s: string | null) => s === 'up' ? 'var(--green)' : s === 'down' ? 'var(--red)' : s === 'degraded' ? 'var(--yellow)' : 'var(--muted)'
@@ -14,6 +16,7 @@ export default function UptimePage() {
const [pinging, setPinging] = useState(false)
const [showAdd, setShowAdd] = useState(false)
const [editingSite, setEditingSite] = useState<Site | null>(null)
const [showNotif, setShowNotif] = useState(false)
const loadSites = useCallback(async () => {
const r = await fetch('/api/config')
@@ -54,7 +57,12 @@ export default function UptimePage() {
<div style={{ width: 260, background: 'var(--surface)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>Uptime</span>
<button onClick={() => setShowAdd(true)} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '4px 10px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer' }}>+ Ekle</button>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={() => setShowNotif(true)} title="Bildirim Ayarları" style={{ background: 'transparent', border: '1px solid var(--border)', borderRadius: 6, padding: '4px 8px', color: 'var(--muted)', fontSize: 12, cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Bell size={13} />
</button>
<button onClick={() => setShowAdd(true)} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '4px 10px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer' }}>+ Ekle</button>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
{sites.length === 0
@@ -131,7 +139,7 @@ export default function UptimePage() {
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>{['Zaman','Durum','MS','HTTP','Hata'].map(h => <th key={h} style={{ padding: '9px 16px', fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textAlign: 'left', textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,.2)' }}>{h}</th>)}</tr>
<tr>{['Zaman', 'Durum', 'MS', 'HTTP', 'Hata'].map(h => <th key={h} style={{ padding: '9px 16px', fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textAlign: 'left', textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,.2)' }}>{h}</th>)}</tr>
</thead>
<tbody>
{data.logs.slice(0, 50).map((log, i) => (
@@ -153,6 +161,7 @@ export default function UptimePage() {
{showAdd && <AddSiteModal onClose={() => setShowAdd(false)} onAdded={() => { loadSites(); setShowAdd(false) }} />}
{editingSite && <AddSiteModal site={editingSite} onClose={() => setEditingSite(null)} onAdded={() => { loadSites(); setEditingSite(null); if (selected?.id === editingSite.id) selectSite(editingSite) }} />}
{showNotif && <NotifModal onClose={() => setShowNotif(false)} />}
</div>
)
}
@@ -169,7 +178,6 @@ function AddSiteModal({ site, onClose, onAdded }: { site?: Site; onClose: () =>
const res = await fetch('/api/config', { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (res.ok) {
if (!site) {
// Yeni site ise hemen ilk pingi at
const newSite = await res.json()
await fetch('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteId: newSite.id }) })
}
@@ -200,3 +208,70 @@ function AddSiteModal({ site, onClose, onAdded }: { site?: Site; onClose: () =>
</div>
)
}
function NotifModal({ onClose }: { onClose: () => void }) {
const [f, setF] = useState<NotifSettings>({ webhook_url: '', telegram_token: '', telegram_chat_id: '', enabled: true })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
fetch('/api/notifications').then(r => r.json()).then(d => {
setF({ webhook_url: d.webhook_url || '', telegram_token: d.telegram_token || '', telegram_chat_id: d.telegram_chat_id || '', enabled: d.enabled ?? true })
setLoading(false)
})
}, [])
const save = async () => {
setSaving(true)
await fetch('/api/notifications', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(f) })
setSaving(false)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const inp: React.CSSProperties = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box' }
const lbl: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 460 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>Bildirim Ayarları</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>
<input type="checkbox" checked={f.enabled} onChange={e => setF(p => ({ ...p, enabled: e.target.checked }))} />
Etkin
</label>
</div>
{loading ? <div style={{ color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Yükleniyor...</div> : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10, color: 'var(--accent)' }}>Webhook</div>
<label style={lbl}>URL (POST JSON)</label>
<input style={inp} placeholder="https://hooks.slack.com/... veya discord webhook" value={f.webhook_url} onChange={e => setF(p => ({ ...p, webhook_url: e.target.value }))} />
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10, color: 'var(--accent)' }}>Telegram</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div><label style={lbl}>Bot Token</label><input style={inp} placeholder="123456:ABC-DEF..." value={f.telegram_token} onChange={e => setF(p => ({ ...p, telegram_token: e.target.value }))} /></div>
<div><label style={lbl}>Chat ID</label><input style={inp} placeholder="-100xxxxxxxxx veya @channel" value={f.telegram_chat_id} onChange={e => setF(p => ({ ...p, telegram_chat_id: e.target.value }))} /></div>
</div>
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', lineHeight: 1.6 }}>
@BotFather'dan bot oluştur → token al → botu kanala ekle → /getUpdates ile chat_id bul
</div>
</div>
</div>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 24 }}>
<button onClick={onClose} style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>Kapat</button>
<button onClick={save} disabled={saving || loading} style={{ flex: 1, background: saved ? 'rgba(0,255,128,.1)' : 'rgba(0,229,255,.1)', border: `1px solid ${saved ? 'rgba(0,255,128,.3)' : 'rgba(0,229,255,.3)'}`, borderRadius: 8, padding: 10, color: saved ? 'var(--green)' : 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
{saving ? 'Kaydediliyor...' : saved ? ' Kaydedildi' : 'Kaydet'}
</button>
</div>
</div>
</div>
)
}
+29
View File
@@ -96,6 +96,14 @@ pool.query(`
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE INDEX IF NOT EXISTS idx_backup_db_ts ON backup_logs(db_id, ts DESC);
CREATE TABLE IF NOT EXISTS notification_settings (
id TEXT PRIMARY KEY DEFAULT 'default',
webhook_url TEXT,
telegram_token TEXT,
telegram_chat_id TEXT,
enabled BOOLEAN NOT NULL DEFAULT true
);
`).catch(console.error)
export type PingLog = {
id: number
@@ -256,4 +264,25 @@ export async function deleteAnalyticsSite(domain: string) {
await pool.query(`DELETE FROM pageviews WHERE domain = $1`, [domain])
}
export interface NotificationSettings {
webhook_url: string | null
telegram_token: string | null
telegram_chat_id: string | null
enabled: boolean
}
export async function getNotificationSettings(): Promise<NotificationSettings | null> {
const res = await pool.query(`SELECT * FROM notification_settings WHERE id = 'default' LIMIT 1`)
return res.rows[0] ?? null
}
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)
VALUES ('default', $1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET webhook_url = $1, telegram_token = $2, telegram_chat_id = $3, enabled = $4
`, [s.webhook_url || null, s.telegram_token || null, s.telegram_chat_id || null, s.enabled])
}
export default pool
+136 -43
View File
@@ -14,11 +14,17 @@ export interface Database {
db_type?: 'postgres' | 'mysql'
}
export function backupFilename(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}.sql`
}
export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
return new Promise((resolve, reject) => {
let command = ''
let args: string[] = []
let env = { ...process.env }
const env = { ...process.env }
if (db.db_type === 'mysql') {
command = 'mysqldump'
@@ -28,7 +34,7 @@ export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
'-u', db.username,
`--password=${db.password}`,
'--no-tablespaces',
db.database
db.database,
]
} else {
command = 'pg_dump'
@@ -44,7 +50,6 @@ export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
}
const child = spawn(command, args, { env })
const chunks: Buffer[] = []
let errorOutput = ''
@@ -52,11 +57,8 @@ export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
child.stderr.on('data', (data) => { errorOutput += data.toString() })
child.on('close', (code) => {
if (code === 0) {
resolve(Buffer.concat(chunks))
} else {
reject(new Error(`${command} exited with code ${code}: ${errorOutput}`))
}
if (code === 0) resolve(Buffer.concat(chunks))
else reject(new Error(`${command} exited with code ${code}: ${errorOutput}`))
})
child.on('error', (err) => {
@@ -65,20 +67,23 @@ export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
})
}
export async function uploadToDropbox(token: string, filename: string, content: Buffer) {
export async function uploadToDropbox(token: string, dbName: string, filename: string, content: Buffer) {
// Path: /DBAdı/2024-01-15_10-30-00.sql
const path = `/${dbName}/${filename}`
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
Authorization: `Bearer ${token}`,
'Dropbox-API-Arg': JSON.stringify({
path: `/${filename}`,
path,
mode: 'add',
autorename: true,
mute: false
mute: false,
}),
'Content-Type': 'application/octet-stream'
'Content-Type': 'application/octet-stream',
},
body: new Uint8Array(content)
body: new Uint8Array(content),
})
if (!response.ok) {
@@ -88,15 +93,12 @@ export async function uploadToDropbox(token: string, filename: string, content:
return await response.json()
}
export async function uploadToGoogleDrive(serviceAccountJsonStr: string, filename: string, content: Buffer, folderId?: string) {
const credentials = JSON.parse(serviceAccountJsonStr)
// 1. Get Access Token using jose
async function getGoogleAccessToken(credentials: Record<string, string>): Promise<string> {
const privateKey = await importPKCS8(credentials.private_key, 'RS256')
const jwt = await new SignJWT({
iss: credentials.client_email,
scope: 'https://www.googleapis.com/auth/drive.file',
aud: 'https://oauth2.googleapis.com/token'
scope: 'https://www.googleapis.com/auth/drive',
aud: 'https://oauth2.googleapis.com/token',
})
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
@@ -108,47 +110,138 @@ export async function uploadToGoogleDrive(serviceAccountJsonStr: string, filenam
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt
})
assertion: jwt,
}),
})
if (!tokenReq.ok) {
throw new Error(`Failed to get Google OAuth token: ${await tokenReq.text()}`)
throw new Error(`Google OAuth token alınamadı: ${await tokenReq.text()}`)
}
const { access_token } = await tokenReq.json()
return access_token
}
// 2. Upload to Drive (Multipart upload to set filename)
const boundary = '-------314159265358979323846'
const delimiter = `\r\n--${boundary}\r\n`
const close_delim = `\r\n--${boundary}--`
async function getOrCreateDriveFolder(
accessToken: string,
folderName: string,
parentId?: string
): Promise<string> {
// Aynı isimli klasör var mı kontrol et
const parentQuery = parentId ? ` and '${parentId}' in parents` : ''
const q = `name='${folderName.replace(/'/g, "\\'")}' and mimeType='application/vnd.google-apps.folder' and trashed=false${parentQuery}`
const metadata: any = {
name: filename,
mimeType: 'application/sql'
}
if (folderId) {
metadata.parents = [folderId]
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 multipartRequestBody = Buffer.concat([
Buffer.from(delimiter + 'Content-Type: application/json\r\n\r\n' + JSON.stringify(metadata) + '\r\n'),
Buffer.from(delimiter + 'Content-Type: application/sql\r\n\r\n'),
content,
Buffer.from(close_delim)
])
// Yoksa oluştur
const meta: Record<string, unknown> = {
name: folderName,
mimeType: 'application/vnd.google-apps.folder',
}
if (parentId) meta.parents = [parentId]
const response = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', {
const createRes = await fetch('https://www.googleapis.com/drive/v3/files', {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': `multipart/related; boundary=${boundary}`
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: new Uint8Array(multipartRequestBody)
body: JSON.stringify(meta),
})
if (!createRes.ok) {
throw new Error(`Klasör oluşturulamadı: ${await createRes.text()}`)
}
const folder = await createRes.json()
return folder.id
}
export async function uploadToGoogleDrive(
serviceAccountJsonStr: string,
dbName: string,
filename: string,
content: Buffer,
parentFolderId?: string
) {
const credentials = JSON.parse(serviceAccountJsonStr)
const accessToken = await getGoogleAccessToken(credentials)
// DB adında alt klasör bul veya oluştur
const targetFolderId = await getOrCreateDriveFolder(accessToken, dbName, parentFolderId || undefined)
// Multipart upload
const boundary = 'vps_panel_backup_boundary'
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${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',
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': `multipart/related; boundary=${boundary}`,
},
body: new Uint8Array(parts),
}
)
if (!response.ok) {
throw new Error(`Google Drive upload failed: ${response.status} ${await response.text()}`)
}
return await response.json()
}
// Sadece credential doğrulama + klasör erişim testi (gerçek yedek almaz)
export async function testGoogleDriveCredentials(
serviceAccountJsonStr: string,
folderId?: string
): Promise<{ ok: boolean; detail?: string; error?: string }> {
try {
const credentials = JSON.parse(serviceAccountJsonStr)
if (!credentials.private_key || !credentials.client_email) {
return { ok: false, error: 'JSON geçersiz: private_key veya client_email eksik' }
}
const accessToken = await getGoogleAccessToken(credentials)
if (folderId) {
const folderRes = await fetch(
`https://www.googleapis.com/drive/v3/files/${folderId}?fields=id,name`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (!folderRes.ok) {
return {
ok: false,
error: `Token alındı ama klasöre erişilemiyor. "${credentials.client_email}" adresini klasörde Düzenleyici olarak eklediniz mi?`,
}
}
const folder = await folderRes.json()
return { ok: true, detail: `Bağlantı OK — Klasör: "${folder.name}" — Hesap: ${credentials.client_email}` }
}
return { ok: true, detail: `Token alındı — Hesap: ${credentials.client_email}` }
} catch (e: any) {
if (e instanceof SyntaxError) {
return { ok: false, error: 'JSON formatı hatalı. Geçerli bir Service Account JSON yapıştırın.' }
}
return { ok: false, error: e.message }
}
}
-33
View File
@@ -1,9 +1,5 @@
import fs from 'fs'
import path from 'path'
import pool from './appDb'
const CONFIG_PATH = path.join(process.cwd(), 'config.json')
export interface Site {
id: string
name: string
@@ -49,36 +45,7 @@ export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
// Migration ve okuma bir arada
export async function readConfig(): Promise<Config> {
// Migration kontrolü
if (fs.existsSync(CONFIG_PATH)) {
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
const oldConfig = JSON.parse(raw) as Config
// Veritabanına aktar
for (const site of oldConfig.sites || []) {
await pool.query(`INSERT INTO config_sites (id, name, url, interval_min) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, [site.id, site.name, site.url, site.interval_min || 5])
}
for (const db of oldConfig.databases || []) {
await pool.query(`INSERT INTO config_databases (id, name, host, port, database, username, password, ssl, color, db_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT DO NOTHING`, [db.id, db.name, db.host, db.port, db.database, db.username, db.password, db.ssl || false, db.color, db.db_type || 'postgres'])
}
for (const svc of oldConfig.services || []) {
await pool.query(`INSERT INTO config_services (id, name, url, icon, description) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING`, [svc.id, svc.name, svc.url, svc.icon, svc.description])
}
for (const host of oldConfig.docker_hosts || []) {
await pool.query(`INSERT INTO config_docker_hosts (id, name, url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [host.id, host.name, host.url])
}
// Dosyanın uzantısını değiştirerek migration'ın bittiğini işaretle
fs.renameSync(CONFIG_PATH, CONFIG_PATH + '.bak')
console.log('[Migration] config.json verileri PostgreSQL tablolalarına aktarıldı ve dosya yedeklendi.')
} catch (e) {
console.error('[Migration] config.json veritabanına aktarılırken hata oluştu:', e)
}
}
// DB'den oku
const [sitesRes, dbsRes, svcsRes, dockerRes] = await Promise.all([
pool.query(`SELECT * FROM config_sites`),
+4 -5
View File
@@ -1,7 +1,7 @@
import * as cron from 'node-cron'
import pool from './appDb'
import { readConfig } from './config'
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDrive } from './backup'
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDrive, backupFilename } from './backup'
export function startBackupCron() {
console.log('[AutoBackup] Arka plan cron servisi başlatıldı.')
@@ -28,15 +28,14 @@ export async function reloadBackupCrons() {
console.log(`[Backup] ${db.name} (${row.cloud_type}) başlatılıyor...`)
const buffer = await createDbDumpBuffer(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql`
const filename = backupFilename()
let message = 'Success'
try {
if (row.cloud_type === 'dropbox') {
await uploadToDropbox(row.credentials, filename, buffer)
await uploadToDropbox(row.credentials, db.name, filename, buffer)
} else if (row.cloud_type === 'gdrive') {
await uploadToGoogleDrive(row.credentials, filename, buffer, row.gdrive_folder_id)
await uploadToGoogleDrive(row.credentials, db.name, filename, buffer, row.gdrive_folder_id)
}
} catch (uploadErr: any) {
console.error(`[Backup] Upload hatası:`, uploadErr)
+11 -3
View File
@@ -143,12 +143,20 @@ export async function getTables(db: Database) {
}
}
const BLOCKED_KEYWORDS = /\b(drop|delete|truncate|alter|create|insert|update|grant|revoke|copy|execute|exec|call|merge)\b/i
export async function runQuery(db: Database, sql: string) {
const trimmed = sql.trim().toLowerCase()
const blocked = ['drop','delete','truncate','alter','create','insert','update','grant','revoke']
if (blocked.some(k => trimmed.startsWith(k)))
const trimmed = sql.trim()
// Yalnızca SELECT veya WITH...SELECT (CTE) izin ver
const firstWord = trimmed.replace(/\/\*[\s\S]*?\*\//g, '').split(/\s+/)[0].toLowerCase()
if (firstWord !== 'select' && firstWord !== 'with')
return { rows: [], fields: [], error: 'Sadece SELECT sorguları çalıştırılabilir.' }
// CTE içinde yazma sorgusu var mı?
if (BLOCKED_KEYWORDS.test(trimmed))
return { rows: [], fields: [], error: 'Sorgu yazma işlemi içeriyor.' }
if (db.db_type === 'mysql') {
const pool = getMysqlPool(db)
const conn = await pool.getConnection()
+52
View File
@@ -0,0 +1,52 @@
import { getNotificationSettings, NotificationSettings } from './appDb'
export async function sendStatusAlert(
siteName: string,
siteUrl: string,
oldStatus: string,
newStatus: string,
ms: number | null
) {
let settings: NotificationSettings | null
try {
settings = await getNotificationSettings()
} catch {
return
}
if (!settings || !settings.enabled) return
const isDown = newStatus !== 'up'
const emoji = newStatus === 'up' ? '✅' : newStatus === 'degraded' ? '⚠️' : '🔴'
const statusLabel = newStatus === 'up' ? 'Düzeldi (UP)' : newStatus === 'degraded' ? 'Yavaş (DEGRADED)' : 'Çöktü (DOWN)'
const msText = ms ? `${ms}ms` : ''
const text = `${emoji} *${siteName}*\nDurum: ${statusLabel}${msText}\nURL: ${siteUrl}`
if (settings.webhook_url) {
fetch(settings.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
site: siteName,
url: siteUrl,
status: newStatus,
previous_status: oldStatus,
ms,
timestamp: new Date().toISOString(),
}),
}).catch(() => {})
}
if (settings.telegram_token && settings.telegram_chat_id) {
fetch(`https://api.telegram.org/bot${settings.telegram_token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: settings.telegram_chat_id,
text: `🔔 *VPS Panel*\n\n${text}`,
parse_mode: 'Markdown',
}),
}).catch(() => {})
}
}
+9 -1
View File
@@ -1,5 +1,6 @@
import { Site } from './config'
import { insertPingLog } from './appDb'
import { insertPingLog, getLastLog } from './appDb'
import { sendStatusAlert } from './notify'
export async function pingSite(site: Site): Promise<{
status: 'up' | 'down' | 'degraded'
@@ -29,7 +30,14 @@ export async function pingSite(site: Site): Promise<{
}
export async function pingAndLog(site: Site) {
const previous = await getLastLog(site.id)
const result = await pingSite(site)
await insertPingLog({ site_id: site.id, ...result })
// Durum değişti mi? → bildirim gönder
if (previous && previous.status !== result.status) {
sendStatusAlert(site.name, site.url, previous.status, result.status, result.ms).catch(() => {})
}
return result
}