feat: update vps-panel backup and notification features
This commit is contained in:
@@ -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="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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user