feat: update vps-panel backup and notification features
This commit is contained in:
-115
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Generated
-7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user