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
+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 })
}
+266 -72
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="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 }))}
<div className="flex-1 space-y-4">
{/* 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>
+149 -28
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) => {
@@ -69,12 +91,12 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
{hosts.length === 0
? <div className="p-5 text-muted text-xs font-mono text-center">Henüz Host yok</div>
: hosts.map(host => (
<div
key={host.id}
onClick={() => select(host)}
<div
key={host.id}
onClick={() => select(host)}
className={`p-3 rounded-xl cursor-pointer transition-all border ${
selected?.id === host.id
? 'bg-accent/5 border-accent/20'
selected?.id === host.id
? 'bg-accent/5 border-accent/20'
: 'border-transparent hover:bg-surface-2 hover:border-border/50'
}`}
>
@@ -122,22 +144,21 @@ 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
? <div className="p-6 text-muted font-mono text-sm text-center animate-pulse">Konteynerler yükleniyor...</div>
: containers.length === 0
: containers.length === 0
? <div className="p-6 text-muted font-mono text-sm text-center">Konteyner bulunamadı.</div>
: <div className="overflow-x-auto">
<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,8 +346,8 @@ 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>
<Button onClick={submit} disabled={loading || !f.name || !f.url} className="flex-1">
+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>
+79 -4
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>
)
}
@@ -165,11 +174,10 @@ function AddSiteModal({ site, onClose, onAdded }: { site?: Site; onClose: () =>
setLoading(true)
const method = site ? 'PUT' : 'POST'
const body = site ? { type: 'site', id: site.id, item: f } : { type: 'site', item: f }
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>
)
}