feat: add docker monitoring and ui fixes
This commit is contained in:
@@ -26,7 +26,7 @@ export async function GET(req: Request) {
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { db_id, schedule, cloud_type, credentials } = body
|
||||
const { db_id, schedule, cloud_type, credentials, gdrive_folder_id } = body
|
||||
|
||||
if (!db_id || !schedule || !cloud_type || !credentials) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
|
||||
@@ -37,13 +37,13 @@ export async function POST(req: Request) {
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await pool.query(
|
||||
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3 WHERE db_id=$4`,
|
||||
[schedule, cloud_type, credentials, db_id]
|
||||
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4 WHERE db_id=$5`,
|
||||
[schedule, cloud_type, credentials, gdrive_folder_id || null, db_id]
|
||||
)
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials) VALUES ($1, $2, $3, $4, $5)`,
|
||||
[generateId(), db_id, schedule, cloud_type, credentials]
|
||||
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[generateId(), db_id, schedule, cloud_type, credentials, gdrive_folder_id || null]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readConfig } from '@/lib/config'
|
||||
import { createPgDumpStream } from '@/lib/backup'
|
||||
import { createDbDumpBuffer } from '@/lib/backup'
|
||||
import pool from '@/lib/appDb'
|
||||
|
||||
// GET /api/db/backup?dbId=...
|
||||
export async function GET(req: Request) {
|
||||
@@ -13,35 +14,20 @@ export async function GET(req: Request) {
|
||||
if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
|
||||
|
||||
try {
|
||||
const child = await createPgDumpStream(db)
|
||||
const buffer = await createDbDumpBuffer(db)
|
||||
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${db.name}_${dateStr}.sql`
|
||||
|
||||
// Stream the output of pg_dump to the response
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
child.stdout.on('data', (chunk) => controller.enqueue(chunk))
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
controller.close()
|
||||
} else {
|
||||
controller.error(new Error(`pg_dump exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
child.on('error', (err) => controller.error(err))
|
||||
},
|
||||
cancel() {
|
||||
child.kill()
|
||||
}
|
||||
})
|
||||
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'])
|
||||
|
||||
return new NextResponse(stream, {
|
||||
return new NextResponse(buffer.toString('utf-8'), {
|
||||
headers: {
|
||||
'Content-Type': 'application/sql',
|
||||
'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'])
|
||||
return NextResponse.json({ error: e.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readConfig } from '@/lib/config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const hostId = searchParams.get('hostId')
|
||||
|
||||
if (!hostId) {
|
||||
return NextResponse.json({ error: 'hostId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
return NextResponse.json({ error: `Docker API Error: ${res.statusText}`, details: text }, { status: res.status })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs'
|
||||
import { Edit2, Trash2, Plus, Code, Globe, CalendarDays } from 'lucide-react'
|
||||
|
||||
type Stats = {
|
||||
total: number
|
||||
@@ -14,29 +19,29 @@ type Stats = {
|
||||
}
|
||||
|
||||
const DAYS_OPTIONS = [
|
||||
{ label: '1g', value: 1 },
|
||||
{ label: '7g', value: 7 },
|
||||
{ label: '30g', value: 30 },
|
||||
{ label: '90g', value: 90 },
|
||||
{ id: '1', label: '1G' },
|
||||
{ id: '7', label: '7G' },
|
||||
{ id: '30', label: '30G' },
|
||||
{ id: '90', label: '90G' },
|
||||
]
|
||||
|
||||
function Bar({ label, value, max, color = 'var(--accent)' }: { label: string; value: number; max: number; color?: string }) {
|
||||
function Bar({ label, value, max, color = 'var(--color-accent)' }: { label: string; value: number; max: number; color?: string }) {
|
||||
const pct = max > 0 ? (value / max) * 100 : 0
|
||||
return (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, marginRight: 8 }}>{label}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', flexShrink: 0 }}>{value.toLocaleString()}</span>
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between mb-1.5">
|
||||
<span className="text-xs text-text font-mono truncate flex-1 mr-2">{label}</span>
|
||||
<span className="text-xs text-muted font-mono shrink-0">{value.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: 'rgba(255,255,255,.06)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 2, transition: 'width .5s ease' }} />
|
||||
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-500 ease-out" style={{ width: `${pct}%`, background: color }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniChart({ data, days }: { data: { day: string; views: number; visitors: number }[]; days: number }) {
|
||||
if (!data.length) return <div style={{ height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Henüz veri yok</div>
|
||||
if (!data.length) return <div className="h-20 flex items-center justify-center text-muted text-xs font-mono">Henüz veri yok</div>
|
||||
|
||||
// Tüm günleri doldur
|
||||
const filled: { day: string; views: number; visitors: number }[] = []
|
||||
@@ -52,11 +57,19 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
|
||||
const maxViews = Math.max(...filled.map(d => d.views), 1)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 80, padding: '0 4px' }}>
|
||||
<div className="flex items-end gap-1 h-20 px-1">
|
||||
{filled.map((d, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, height: '100%', justifyContent: 'flex-end' }}
|
||||
title={`${d.day}: ${d.views} görüntüleme, ${d.visitors} ziyaretçi`}>
|
||||
<div style={{ width: '100%', background: 'var(--accent)', borderRadius: '2px 2px 0 0', opacity: .85, height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 4 : 0)}%`, transition: 'height .3s ease', minHeight: d.views > 0 ? 2 : 0 }} />
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-center gap-[1px] h-full justify-end"
|
||||
>
|
||||
<div
|
||||
className="w-full bg-accent/80 rounded-t-sm transition-all duration-300 ease-out"
|
||||
style={{
|
||||
height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 4 : 0)}%`,
|
||||
minHeight: d.views > 0 ? 2 : 0
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -66,7 +79,7 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
|
||||
export default function AnalyticsPage() {
|
||||
const [domains, setDomains] = useState<string[]>([])
|
||||
const [selectedDomain, setSelectedDomain] = useState<string | null>(null)
|
||||
const [days, setDays] = useState(30)
|
||||
const [days, setDays] = useState('30')
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSnippet, setShowSnippet] = useState(false)
|
||||
@@ -118,7 +131,6 @@ export default function AnalyticsPage() {
|
||||
})
|
||||
setSelectedDomain(newName.trim())
|
||||
|
||||
// Refresh domains after brief delay for state to sync
|
||||
setTimeout(loadDomains, 100)
|
||||
}
|
||||
|
||||
@@ -137,236 +149,275 @@ export default function AnalyticsPage() {
|
||||
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
|
||||
|
||||
return (
|
||||
<div style={{ padding: 28, maxWidth: 1100 }}>
|
||||
<div className="p-8 max-w-[1200px] mx-auto fade-up">
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
|
||||
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Analytics</h1>
|
||||
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cookie-free, self-hosted — Plausible benzeri</p>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight mb-1">Analytics</h1>
|
||||
<p className="text-xs text-muted font-mono tracking-wide">Cookie-free, self-hosted website tracking</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Domain Actions */}
|
||||
{selectedDomain && (
|
||||
<>
|
||||
<button onClick={handleRename} style={{ background: 'transparent', border: '1px solid var(--border)', borderRadius: 7, padding: '8px 14px', color: 'var(--text)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Düzenle</button>
|
||||
<button onClick={handleDelete} style={{ background: 'rgba(255,50,50,.1)', border: '1px solid rgba(255,50,50,.3)', borderRadius: 7, padding: '8px 14px', color: 'var(--red, #ff5c5c)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Sil</button>
|
||||
</>
|
||||
<div className="flex items-center gap-2 bg-surface-2 p-1 border border-border/50 rounded-xl">
|
||||
<Button variant="ghost" size="sm" onClick={handleRename} className="h-8 px-3 text-xs">
|
||||
<Edit2 className="w-3.5 h-3.5 mr-1.5" /> Düzenle
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<Button variant="ghost" size="sm" onClick={handleDelete} className="h-8 px-3 text-xs text-destructive hover:text-destructive hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1.5" /> Sil
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setShowAddSite(true)}
|
||||
style={{ background: 'rgba(0,229,255,.12)', border: '1px solid rgba(0,229,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
|
||||
+ Site Ekle
|
||||
</button>
|
||||
<button onClick={() => setShowSnippet(true)}
|
||||
style={{ background: 'rgba(123,97,255,.12)', border: '1px solid rgba(123,97,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--purple, #7b61ff)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
|
||||
{'</>'} Snippet
|
||||
</button>
|
||||
{/* Days filter */}
|
||||
<div style={{ display: 'flex', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 7, overflow: 'hidden' }}>
|
||||
{DAYS_OPTIONS.map(opt => (
|
||||
<button key={opt.value} onClick={() => setDays(opt.value)}
|
||||
style={{ padding: '7px 14px', fontSize: 12, fontFamily: 'monospace', cursor: 'pointer', background: days === opt.value ? 'rgba(0,229,255,.12)' : 'transparent', color: days === opt.value ? 'var(--accent)' : 'var(--muted)', border: 'none', borderRight: '1px solid var(--border)' }}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddSite(true)} className="h-10">
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Site Ekle
|
||||
</Button>
|
||||
|
||||
<Button variant="default" size="sm" onClick={() => setShowSnippet(true)} className="h-10 bg-purple-500/10 text-purple-400 border-purple-500/30 hover:bg-purple-500/20">
|
||||
<Code className="w-4 h-4 mr-1.5" /> Snippet
|
||||
</Button>
|
||||
|
||||
{/* Days filter - using AnimatedTabs */}
|
||||
<div className="ml-2">
|
||||
<AnimatedTabs
|
||||
activeTab={days}
|
||||
onChange={(id) => setDays(id)}
|
||||
tabs={DAYS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain tabs */}
|
||||
{domains.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
|
||||
<div className="flex flex-wrap gap-2 mb-6 border-b border-border/50 pb-px">
|
||||
{domains.map(d => (
|
||||
<button key={d} onClick={() => setSelectedDomain(d)}
|
||||
style={{ padding: '8px 14px', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'none', border: 'none', borderBottom: `2px solid ${selectedDomain === d ? 'var(--accent)' : 'transparent'}`, color: selectedDomain === d ? 'var(--accent)' : 'var(--muted)', marginBottom: -1 }}>
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setSelectedDomain(d)}
|
||||
className={`px-4 py-2 text-sm font-semibold border-b-2 transition-colors flex items-center gap-2 ${
|
||||
selectedDomain === d
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-muted hover:text-text hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ background: 'var(--surface)', border: '1px dashed var(--border)', borderRadius: 12, padding: '40px', textAlign: 'center', marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>Henüz veri yok</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 16 }}>Sitelerine tracking snippet ekle, veriler burada görünecek.</div>
|
||||
<button onClick={() => setShowSnippet(true)}
|
||||
style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 7, padding: '9px 18px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontFamily: 'monospace' }}>
|
||||
{'</>'} Snippet'ı Göster
|
||||
</button>
|
||||
</div>
|
||||
<Card className="border-dashed border-2 bg-surface/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Globe className="w-12 h-12 text-muted/30 mb-4" />
|
||||
<h3 className="text-lg font-bold mb-2">Henüz veri yok</h3>
|
||||
<p className="text-sm text-muted mb-6">Sitelerine tracking snippet ekle, veriler burada görünecek.</p>
|
||||
<Button onClick={() => setShowSnippet(true)}>
|
||||
<Code className="w-4 h-4 mr-2" /> Snippet'ı Göster
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{stats && !loading && (
|
||||
<>
|
||||
<div className="space-y-6 fade-up">
|
||||
{/* Big numbers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 14, marginBottom: 20 }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ label: 'Toplam Görüntüleme', value: stats.total.toLocaleString(), color: 'var(--accent)' },
|
||||
{ label: 'Tekil Ziyaretçi', value: stats.unique.toLocaleString(), color: 'var(--green)' },
|
||||
{ label: 'Toplam Görüntüleme', value: stats.total.toLocaleString(), color: 'text-accent' },
|
||||
{ label: 'Tekil Ziyaretçi', value: stats.unique.toLocaleString(), color: 'text-success' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '18px 20px' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>{s.label}</div>
|
||||
<div style={{ fontSize: 32, fontWeight: 800, color: s.color, letterSpacing: -1 }}>{s.value}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>son {days} gün</div>
|
||||
</div>
|
||||
<Card key={s.label} className="bg-surface/50 border-border/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="text-[10px] text-muted font-mono uppercase tracking-widest mb-2">{s.label}</div>
|
||||
<div className={`text-4xl font-extrabold tracking-tighter ${s.color}`}>{s.value}</div>
|
||||
<div className="text-xs text-muted font-mono mt-2 flex items-center gap-1">
|
||||
<CalendarDays className="w-3 h-3" /> son {days} gün
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 20px', marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 12 }}>Günlük Görüntülemeler</div>
|
||||
<MiniChart data={stats.daily} days={days} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>{days} gün önce</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>bugün</span>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="bg-surface/50 border-border/50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Günlük Görüntülemeler</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MiniChart data={stats.daily} days={parseInt(days)} />
|
||||
<div className="flex justify-between mt-2">
|
||||
<span className="text-[10px] text-muted font-mono">{days} gün önce</span>
|
||||
<span className="text-[10px] text-muted font-mono">bugün</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3-col breakdown */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14, marginBottom: 14 }}>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Top pages */}
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Sayfalar</div>
|
||||
{stats.topPages.length === 0
|
||||
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Veri yok</div>
|
||||
: stats.topPages.map(p => <Bar key={p.path} label={p.path || '/'} value={p.views} max={stats.topPages[0]?.views ?? 1} />)
|
||||
}
|
||||
</div>
|
||||
<Card className="bg-surface/50 border-border/50">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Sayfalar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.topPages.length === 0
|
||||
? <div className="text-xs text-muted font-mono">Veri yok</div>
|
||||
: stats.topPages.map(p => <Bar key={p.path} label={p.path || '/'} value={p.views} max={stats.topPages[0]?.views ?? 1} />)
|
||||
}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Referrers */}
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Trafik Kaynağı</div>
|
||||
{stats.topReferrers.length === 0
|
||||
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Direct / bilinmiyor</div>
|
||||
: stats.topReferrers.map(r => {
|
||||
let label = r.referrer
|
||||
try { label = new URL(r.referrer).hostname } catch { /* ignore */ }
|
||||
return <Bar key={r.referrer} label={label} value={r.visits} max={stats.topReferrers[0]?.visits ?? 1} color="var(--green)" />
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<Card className="bg-surface/50 border-border/50">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Trafik Kaynağı</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.topReferrers.length === 0
|
||||
? <div className="text-xs text-muted font-mono">Direct / bilinmiyor</div>
|
||||
: stats.topReferrers.map(r => {
|
||||
let label = r.referrer
|
||||
try { label = new URL(r.referrer).hostname } catch { /* ignore */ }
|
||||
return <Bar key={r.referrer} label={label} value={r.visits} max={stats.topReferrers[0]?.visits ?? 1} color="var(--color-success)" />
|
||||
})
|
||||
}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Countries */}
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Ülkeler</div>
|
||||
{stats.byCountry.length === 0
|
||||
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cloudflare proxy gerekli</div>
|
||||
: stats.byCountry.map(c => <Bar key={c.country} label={c.country} value={c.n} max={stats.byCountry[0]?.n ?? 1} color="var(--yellow)" />)
|
||||
}
|
||||
</div>
|
||||
<Card className="bg-surface/50 border-border/50">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Ülkeler</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.byCountry.length === 0
|
||||
? <div className="text-xs text-muted font-mono">Cloudflare proxy gerekli</div>
|
||||
: stats.byCountry.map(c => <Bar key={c.country} label={c.country} value={c.n} max={stats.byCountry[0]?.n ?? 1} color="var(--color-warning)" />)
|
||||
}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Device / Browser / OS */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14 }}>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{[
|
||||
{ title: 'Cihaz', data: stats.byDevice.map(d => ({ label: d.device, value: d.n })) },
|
||||
{ title: 'Tarayıcı', data: stats.byBrowser.map(d => ({ label: d.browser, value: d.n })) },
|
||||
{ title: 'İşletim Sistemi', data: stats.byOs.map(d => ({ label: d.os, value: d.n })) },
|
||||
].map(section => (
|
||||
<div key={section.title} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>{section.title}</div>
|
||||
{section.data.length === 0
|
||||
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Veri yok</div>
|
||||
: section.data.map(d => <Bar key={d.label} label={d.label} value={d.value} max={section.data[0]?.value ?? 1} color="rgba(123,97,255,.8)" />)
|
||||
}
|
||||
</div>
|
||||
<Card key={section.title} className="bg-surface/50 border-border/50">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">{section.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{section.data.length === 0
|
||||
? <div className="text-xs text-muted font-mono">Veri yok</div>
|
||||
: section.data.map(d => <Bar key={d.label} label={d.label} value={d.value} max={section.data[0]?.value ?? 1} color="#7b61ff" />)
|
||||
}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}>Yükleniyor...</div>
|
||||
<div className="py-20 text-center text-muted font-mono text-sm animate-pulse">Yükleniyor...</div>
|
||||
)}
|
||||
|
||||
{/* Snippet modal */}
|
||||
{showSnippet && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={() => setShowSnippet(false)}>
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 560, maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Tracking Snippet</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}>
|
||||
Sitendeki <code style={{ background: 'rgba(0,0,0,.3)', padding: '2px 6px', borderRadius: 4, fontFamily: 'monospace' }}><head></code> tagının içine ekle:
|
||||
</div>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowSnippet(false)}>
|
||||
<Card className="w-full max-w-[560px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
|
||||
<CardHeader>
|
||||
<CardTitle>Tracking Snippet</CardTitle>
|
||||
<CardDescription>
|
||||
Sitendeki <code className="bg-black/30 px-1.5 py-0.5 rounded border border-border">{'<head>'}</code> tagının içine ekle:
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<pre className="bg-black/40 border border-border rounded-xl p-4 font-mono text-xs text-accent overflow-x-auto whitespace-pre-wrap word-break-all shadow-inner">
|
||||
{snippet}
|
||||
</pre>
|
||||
|
||||
<pre style={{ background: 'rgba(0,0,0,.4)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 16px', fontFamily: 'monospace', fontSize: 12, color: 'var(--accent)', overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all', marginBottom: 16 }}>
|
||||
{snippet}
|
||||
</pre>
|
||||
<div className="space-y-2 text-xs text-muted font-mono bg-surface-2 p-3 rounded-lg border border-border/50">
|
||||
<div><span className="text-success font-semibold">data-domain</span> — hangi domain olduğunu belirtir, değiştirme</div>
|
||||
<div><span className="text-success font-semibold">defer</span> — sayfayı yavaşlatmaz, arka planda yüklenir</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8, fontFamily: 'monospace' }}>
|
||||
<span style={{ color: 'var(--green)' }}>data-domain</span> — hangi domain olduğunu belirtir, değiştirme
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20, fontFamily: 'monospace' }}>
|
||||
<span style={{ color: 'var(--green)' }}>defer</span> — sayfayı yavaşlatmaz, arka planda yüklenir
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(snippet); }}
|
||||
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 }}>
|
||||
Kopyala
|
||||
</button>
|
||||
<button onClick={() => setShowSnippet(false)}
|
||||
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
|
||||
Kapat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button onClick={() => { navigator.clipboard.writeText(snippet); }} className="flex-1">
|
||||
Kopyala
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setShowSnippet(false)} className="flex-1">
|
||||
Kapat
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Site modal */}
|
||||
{showAddSite && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={() => setShowAddSite(false)}>
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 400, maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Yeni Site Ekle</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}>
|
||||
Takip etmek istediğiniz alan adını girin (örn: example.com)
|
||||
</div>
|
||||
|
||||
<input
|
||||
style={{ 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', marginBottom: 20 }}
|
||||
placeholder="example.com"
|
||||
value={newDomain}
|
||||
onChange={e => setNewDomain(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newDomain.trim()) {
|
||||
fetch('/api/analytics/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: newDomain.trim() })
|
||||
}).then(() => {
|
||||
setNewDomain('');
|
||||
setShowAddSite(false);
|
||||
loadDomains();
|
||||
setSelectedDomain(newDomain.trim());
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
disabled={!newDomain.trim()}
|
||||
onClick={() => {
|
||||
fetch('/api/analytics/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: newDomain.trim() })
|
||||
}).then(() => {
|
||||
setNewDomain('');
|
||||
setShowAddSite(false);
|
||||
loadDomains();
|
||||
setSelectedDomain(newDomain.trim());
|
||||
})
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAddSite(false)}>
|
||||
<Card className="w-full max-w-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
|
||||
<CardHeader>
|
||||
<CardTitle>Yeni Site Ekle</CardTitle>
|
||||
<CardDescription>Takip etmek istediğiniz alan adını girin (örn: example.com)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
placeholder="example.com"
|
||||
value={newDomain}
|
||||
onChange={e => setNewDomain(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newDomain.trim()) {
|
||||
fetch('/api/analytics/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: newDomain.trim() })
|
||||
}).then(() => {
|
||||
setNewDomain('');
|
||||
setShowAddSite(false);
|
||||
loadDomains();
|
||||
setSelectedDomain(newDomain.trim());
|
||||
})
|
||||
}
|
||||
}}
|
||||
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 }}>
|
||||
Ekle
|
||||
</button>
|
||||
<button onClick={() => setShowAddSite(false)}
|
||||
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
|
||||
İptal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setShowAddSite(false)} className="flex-1">
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!newDomain.trim()}
|
||||
onClick={() => {
|
||||
fetch('/api/analytics/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: newDomain.trim() })
|
||||
}).then(() => {
|
||||
setNewDomain('');
|
||||
setShowAddSite(false);
|
||||
loadDomains();
|
||||
setSelectedDomain(newDomain.trim());
|
||||
})
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Ekle
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card'
|
||||
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'
|
||||
|
||||
type Db = { id: string; name: string; host: string; port: number; database: string; username: string; color: string; ssl: boolean }
|
||||
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 }
|
||||
type QueryResult = { rows: Record<string, unknown>[]; fields: string[]; error: string | null }
|
||||
|
||||
@@ -50,25 +55,38 @@ export default function DatabasesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* LEFT */}
|
||||
<div style={{ width: 240, 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 }}>Databases</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 className="w-64 bg-surface border-r border-border flex flex-col shrink-0">
|
||||
<div className="p-4 border-b border-border/50 flex justify-between items-center">
|
||||
<span className="text-xs font-mono text-muted uppercase tracking-widest">Databases</span>
|
||||
<Button variant="default" size="sm" onClick={() => setShowAdd(true)} className="h-7 text-xs">
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Ekle
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 custom-scrollbar">
|
||||
{dbs.length === 0
|
||||
? <div style={{ padding: 20, color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace', textAlign: 'center' }}>Henüz DB yok</div>
|
||||
? <div className="p-5 text-muted text-xs font-mono text-center">Henüz DB yok</div>
|
||||
: dbs.map(db => (
|
||||
<div key={db.id} onClick={() => select(db)} style={{ padding: '10px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 4, background: selected?.id === db.id ? 'rgba(255,255,255,.04)' : 'transparent', border: `1px solid ${selected?.id === db.id ? 'var(--border)' : 'transparent'}` }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: db.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{db.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); deleteDb(db.id) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer', flexShrink: 0 }}>✕</button>
|
||||
<div
|
||||
key={db.id}
|
||||
onClick={() => select(db)}
|
||||
className={`p-3 rounded-xl cursor-pointer transition-all border ${
|
||||
selected?.id === db.id
|
||||
? 'bg-accent/5 border-accent/20'
|
||||
: 'border-transparent hover:bg-surface-2 hover:border-border/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div className="w-2 h-2 rounded-full shrink-0 shadow-sm" style={{ background: db.color, boxShadow: `0 0 6px ${db.color}` }} />
|
||||
<span className="text-sm font-semibold flex-1 truncate">{db.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); deleteDb(db.id) }} className="text-muted hover:text-destructive transition-colors shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', paddingLeft: 16, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{db.host}/{db.database}
|
||||
<div className="flex justify-between items-center text-[10px] text-muted font-mono pl-4">
|
||||
<span className="truncate mr-2">{db.host}/{db.database}</span>
|
||||
<span className="bg-white/5 px-1.5 py-0.5 rounded text-[9px] uppercase">{db.db_type === 'mysql' ? 'MY' : 'PG'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -76,90 +94,149 @@ export default function DatabasesPage() {
|
||||
</div>
|
||||
|
||||
{/* RIGHT */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<div className="flex-1 overflow-auto custom-scrollbar relative">
|
||||
{!selected
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}>← DB seç</div>
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted font-mono text-sm opacity-50">
|
||||
<DatabaseIcon className="w-5 h-5 mr-3" />
|
||||
Sol menüden bir veritabanı seçin
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: '50%', background: selected.color }} />
|
||||
<h2 style={{ fontSize: 18, fontWeight: 800 }}>{selected.name}</h2>
|
||||
<div className="p-8 max-w-6xl mx-auto fade-up">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: selected.color, boxShadow: `0 0 10px ${selected.color}` }} />
|
||||
<h2 className="text-2xl font-extrabold tracking-tight">{selected.name}</h2>
|
||||
</div>
|
||||
<div className="text-sm text-muted font-mono bg-surface-2 w-fit px-3 py-1 rounded-md border border-border/50">
|
||||
{selected.username}@{selected.host}:{selected.port}/{selected.database}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>{selected.username}@{selected.host}:{selected.port}/{selected.database}</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, marginBottom: 20 }}>
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
{[
|
||||
{ l: 'Boyut', v: String(stats.size) },
|
||||
{ l: 'Aktif Bağlantı', v: String(stats.active_connections) },
|
||||
{ l: 'Tablo Sayısı', v: String(stats.table_count) },
|
||||
{ l: 'Boyut', v: String(stats.size), icon: HardDrive },
|
||||
{ l: 'Aktif Bağlantı', v: String(stats.active_connections), icon: ActivityIcon },
|
||||
{ l: 'Tablo Sayısı', v: String(stats.table_count), icon: TableIcon },
|
||||
].map(s => (
|
||||
<div key={s.l} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }}>{s.l}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--accent)' }}>{s.v}</div>
|
||||
</div>
|
||||
<Card key={s.l} className="bg-surface/30">
|
||||
<CardContent className="p-5 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted font-mono uppercase tracking-wider mb-2">{s.l}</div>
|
||||
<div className="text-2xl font-extrabold text-accent">{s.v}</div>
|
||||
</div>
|
||||
<s.icon className="w-5 h-5 text-accent/30" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 16, gap: 4 }}>
|
||||
{(['tables', 'query', 'stats', 'backups'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)} style={{ padding: '8px 16px', fontSize: 13, fontWeight: 600, color: tab === t ? 'var(--accent)' : 'var(--muted)', background: 'none', border: 'none', borderBottomWidth: 2, borderBottomStyle: 'solid', borderBottomColor: tab === t ? 'var(--accent)' : 'transparent', cursor: 'pointer', marginBottom: -1 }}>
|
||||
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : t === 'stats' ? 'İstatistik' : 'Yedekler'}
|
||||
</button>
|
||||
))}
|
||||
<div className="mb-6">
|
||||
<AnimatedTabs
|
||||
activeTab={tab}
|
||||
onChange={(t: any) => setTab(t)}
|
||||
tabs={[
|
||||
{ id: 'tables', label: 'Tablolar', icon: <TableIcon className="w-4 h-4" /> },
|
||||
{ id: 'query', label: 'Sorgu', icon: <Play className="w-4 h-4" /> },
|
||||
{ id: 'stats', label: 'İstatistik', icon: <BarChart2 className="w-4 h-4" /> },
|
||||
{ id: 'backups', label: 'Yedekler', icon: <Download className="w-4 h-4" /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
{tab === 'tables' && (
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<Card className="overflow-hidden">
|
||||
{tables.length === 0
|
||||
? <div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Tablo bulunamadı veya yükleniyor...</div>
|
||||
: <table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead><tr>{['Tablo','Satır','Boyut'].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>
|
||||
{tables.map(t => (
|
||||
<tr key={t.table_name} style={{ cursor: 'pointer' }} onClick={() => { setTab('query'); setSql(`SELECT * FROM ${t.table_name} LIMIT 50`) }}>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, fontFamily: 'monospace', color: 'var(--accent)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{t.table_name}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 12, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{Number(t.row_count).toLocaleString()}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{t.size}</td>
|
||||
? <div className="p-6 text-muted font-mono text-sm text-center">Tablo bulunamadı veya yükleniyor...</div>
|
||||
: <div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Tablo','Satır','Boyut'].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>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tables.map((t, i) => (
|
||||
<tr
|
||||
key={t.table_name}
|
||||
onClick={() => { setTab('query'); setSql(`SELECT * FROM ${t.table_name} LIMIT 50`) }}
|
||||
className="group cursor-pointer hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className={`px-6 py-4 text-sm font-mono text-accent ${i !== tables.length-1 ? 'border-b border-border/50' : ''}`}>
|
||||
{t.table_name}
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-sm font-mono ${i !== tables.length-1 ? 'border-b border-border/50' : ''}`}>
|
||||
{Number(t.row_count).toLocaleString()}
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-sm font-mono text-muted ${i !== tables.length-1 ? 'border-b border-border/50' : ''}`}>
|
||||
{t.size}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Query */}
|
||||
{tab === 'query' && (
|
||||
<div>
|
||||
<textarea value={sql} onChange={e => setSql(e.target.value)} rows={5}
|
||||
onKeyDown={e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') runQuery() }}
|
||||
style={{ width: '100%', background: 'rgba(0,0,0,.4)', border: '1px solid var(--border)', borderRadius: 8, padding: '12px 14px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', resize: 'vertical', outline: 'none', marginBottom: 8 }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Cmd+Enter · Sadece SELECT</span>
|
||||
<button onClick={runQuery} disabled={qLoading} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 20px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
|
||||
{qLoading ? '...' : '▶ Çalıştır'}
|
||||
</button>
|
||||
<div className="space-y-4 fade-up">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={sql}
|
||||
onChange={e => setSql(e.target.value)}
|
||||
rows={5}
|
||||
onKeyDown={e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') runQuery() }}
|
||||
className="w-full bg-black/40 border border-border rounded-xl p-4 text-sm font-mono text-text resize-y outline-none focus:ring-1 focus:ring-accent/50 transition-all shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted font-mono bg-surface-2 px-2 py-1 rounded">Cmd+Enter · Sadece SELECT</span>
|
||||
<Button onClick={runQuery} disabled={qLoading}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{qLoading ? 'Çalışıyor...' : 'Çalıştır'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<Card className="overflow-hidden fade-up">
|
||||
{result.error
|
||||
? <div style={{ padding: 14, color: 'var(--red)', fontFamily: 'monospace', fontSize: 12 }}>✕ {result.error}</div>
|
||||
? <div className="p-4 text-destructive font-mono text-sm bg-destructive/5 flex items-center gap-2"><ShieldAlert className="w-4 h-4"/> {result.error}</div>
|
||||
: <>
|
||||
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--border)', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{result.rows.length} satır</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead><tr>{result.fields.map(f => <th key={f} style={{ padding: '8px 14px', fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textAlign: 'left', textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,.2)', whiteSpace: 'nowrap' }}>{f}</th>)}</tr></thead>
|
||||
<div className="px-5 py-2.5 border-b border-border text-xs text-muted font-mono bg-black/20">
|
||||
{result.rows.length} satır getirildi
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{result.fields.map(f => (
|
||||
<th key={f} className="px-5 py-3 text-[10px] text-muted font-mono text-left uppercase tracking-wider border-b border-border bg-black/20 whitespace-nowrap">
|
||||
{f}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.slice(0, 200).map((row, i) => (
|
||||
<tr key={i}>
|
||||
{result.fields.map(f => <td key={f} style={{ padding: '8px 14px', fontSize: 12, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)', whiteSpace: 'nowrap', color: row[f] == null ? 'var(--muted)' : 'var(--text)' }}>{row[f] == null ? 'NULL' : String(row[f])}</td>)}
|
||||
<tr key={i} className="hover:bg-white/[0.01]">
|
||||
{result.fields.map(f => (
|
||||
<td key={f} className={`px-5 py-3 text-xs font-mono whitespace-nowrap ${i !== Math.min(result.rows.length, 200)-1 ? 'border-b border-border/30' : ''} ${row[f] == null ? 'text-muted/50' : 'text-text/90'}`}>
|
||||
{row[f] == null ? 'NULL' : String(row[f])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -167,16 +244,16 @@ export default function DatabasesPage() {
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats detail */}
|
||||
{tab === 'stats' && stats && (
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
|
||||
<pre style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--muted)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(stats, null, 2)}</pre>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Backups */}
|
||||
@@ -191,7 +268,7 @@ export default function DatabasesPage() {
|
||||
}
|
||||
|
||||
function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
|
||||
const [f, setF] = useState({ name: '', host: 'localhost', port: 5432, database: '', username: 'postgres', password: '', ssl: false, color: '#00e5ff' })
|
||||
const [f, setF] = useState({ name: '', host: 'localhost', port: 5432, database: '', username: 'postgres', password: '', ssl: false, color: '#00e5ff', db_type: 'postgres' as 'postgres' | 'mysql' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [connString, setConnString] = useState('')
|
||||
@@ -200,13 +277,15 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
|
||||
setConnString(val)
|
||||
try {
|
||||
const u = new URL(val)
|
||||
if (u.protocol === 'postgres:' || u.protocol === 'postgresql:') {
|
||||
if (u.protocol === 'postgres:' || u.protocol === 'postgresql:' || u.protocol === 'mysql:') {
|
||||
const isMysql = u.protocol === 'mysql:'
|
||||
setF(p => ({
|
||||
...p,
|
||||
db_type: isMysql ? 'mysql' : 'postgres',
|
||||
host: u.hostname || p.host,
|
||||
port: parseInt(u.port) || 5432,
|
||||
port: parseInt(u.port) || (isMysql ? 3306 : 5432),
|
||||
database: u.pathname.slice(1) || p.database,
|
||||
username: u.username || p.username,
|
||||
username: u.username || (isMysql ? 'root' : 'postgres'),
|
||||
password: decodeURIComponent(u.password) || p.password,
|
||||
ssl: u.searchParams.get('sslmode') !== 'disable' && u.searchParams.get('sslmode') !== null ? true : p.ssl
|
||||
}))
|
||||
@@ -218,7 +297,6 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true); setError('')
|
||||
// Test bağlantısı
|
||||
try {
|
||||
const testRes = await fetch('/api/db', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', db: { ...f, id: '__test__' } }) })
|
||||
const testData = await testRes.json()
|
||||
@@ -233,46 +311,90 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
|
||||
return
|
||||
}
|
||||
|
||||
// Config'e kaydet
|
||||
const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'database', item: f }) })
|
||||
if (res.ok) { onAdded() } else { setError((await res.json()).error ?? 'Hata'); setLoading(false) }
|
||||
}
|
||||
|
||||
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 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: 420 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 20 }}>Yeni DB Bağlantısı</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div><label style={lbl}>Bağlantı URL (Opsiyonel)</label><input style={inp} placeholder="postgres://user:pass@host:5432/db" value={connString} onChange={e => handleConnectionString(e.target.value)} /></div>
|
||||
<div><label style={lbl}>Ad</label><input style={inp} placeholder="Kotekli Prod" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} /></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px', gap: 8 }}>
|
||||
<div><label style={lbl}>Host</label><input style={inp} value={f.host} onChange={e => setF(p => ({ ...p, host: e.target.value }))} /></div>
|
||||
<div><label style={lbl}>Port</label><input style={inp} type="number" value={f.port} onChange={e => setF(p => ({ ...p, port: parseInt(e.target.value) }))} /></div>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<Card className="w-full max-w-[440px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
|
||||
<CardHeader className="pb-4 border-b border-border/50">
|
||||
<CardTitle>Yeni Veritabanı Bağlantısı</CardTitle>
|
||||
<CardDescription>Veritabanı erişimi için gerekli bilgileri girin.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bağlantı URL (Opsiyonel)</label>
|
||||
<Input placeholder="postgres://... veya mysql://..." value={connString} onChange={e => handleConnectionString(e.target.value)} />
|
||||
</div>
|
||||
<div><label style={lbl}>Database</label><input style={inp} placeholder="mydb" value={f.database} onChange={e => setF(p => ({ ...p, database: e.target.value }))} /></div>
|
||||
<div><label style={lbl}>Username</label><input style={inp} value={f.username} onChange={e => setF(p => ({ ...p, username: e.target.value }))} /></div>
|
||||
<div><label style={lbl}>Password</label><input type="password" style={inp} value={f.password} onChange={e => setF(p => ({ ...p, password: e.target.value }))} /></div>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--muted)', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.ssl} onChange={e => setF(p => ({ ...p, ssl: e.target.checked }))} /> SSL
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>RENK</span>
|
||||
<input type="color" value={f.color} onChange={e => setF(p => ({ ...p, color: e.target.value }))} style={{ width: 30, height: 26, borderRadius: 4, border: '1px solid var(--border)', cursor: 'pointer', background: 'transparent' }} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Tür</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.db_type}
|
||||
onChange={e => {
|
||||
const t = e.target.value as 'postgres' | 'mysql'
|
||||
setF(p => ({ ...p, db_type: t, port: t === 'mysql' ? 3306 : 5432, username: t === 'mysql' ? 'root' : 'postgres' }))
|
||||
}}
|
||||
>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Ad</label>
|
||||
<Input placeholder="Kotekli Prod" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div style={{ color: 'var(--red)', fontSize: 12, fontFamily: 'monospace', marginTop: 10 }}>✕ {error}</div>}
|
||||
<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 || !f.database} 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 ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_80px] gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Host</label>
|
||||
<Input value={f.host} onChange={e => setF(p => ({ ...p, host: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Port</label>
|
||||
<Input type="number" value={f.port} onChange={e => setF(p => ({ ...p, port: parseInt(e.target.value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Database</label>
|
||||
<Input placeholder="mydb" value={f.database} onChange={e => setF(p => ({ ...p, database: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Username</label>
|
||||
<Input value={f.username} onChange={e => setF(p => ({ ...p, username: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Password</label>
|
||||
<Input type="password" value={f.password} onChange={e => setF(p => ({ ...p, password: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<label className="flex items-center gap-2 text-sm text-muted cursor-pointer hover:text-text transition-colors">
|
||||
<input type="checkbox" checked={f.ssl} onChange={e => setF(p => ({ ...p, ssl: e.target.checked }))} className="rounded border-border bg-black/30 w-4 h-4 accent-accent" />
|
||||
SSL Kullan
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-muted uppercase">Renk</span>
|
||||
<input type="color" value={f.color} onChange={e => setF(p => ({ ...p, color: e.target.value }))} className="w-8 h-8 rounded cursor-pointer bg-transparent border-0 p-0" />
|
||||
</div>
|
||||
</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.database} className="flex-1">
|
||||
{loading ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -282,7 +404,7 @@ function BackupTab({ db }: { db: Db }) {
|
||||
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: '' })
|
||||
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '', gdrive_folder_id: '' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -292,7 +414,7 @@ function BackupTab({ db }: { db: Db }) {
|
||||
setConfig(data.config)
|
||||
setLogs(data.logs)
|
||||
if (data.config) {
|
||||
setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials })
|
||||
setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials, gdrive_folder_id: data.config.gdrive_folder_id || '' })
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
@@ -319,65 +441,116 @@ function BackupTab({ db }: { db: Db }) {
|
||||
body: JSON.stringify({ db_id: db.id })
|
||||
})
|
||||
setConfig(null)
|
||||
setF({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
|
||||
setF({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '', gdrive_folder_id: '' })
|
||||
}
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Yükleniyor...</div>
|
||||
|
||||
const inp = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 12 }
|
||||
const lbl = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 } as any
|
||||
if (loading) return <div className="text-muted text-sm font-mono animate-pulse">Yükleniyor...</div>
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700 }}>Otomatik Yedekleme</h3>
|
||||
<a href={`/api/db/backup?dbId=${db.id}`} download style={{ background: 'rgba(255,255,255,.1)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 12px', color: 'var(--text)', fontSize: 12, textDecoration: 'none' }}>
|
||||
↓ Hemen İndir
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div><label style={lbl}>Cron Schedule (node-cron)</label><input style={inp} value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" /></div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={lbl}>Cloud Sağlayıcı</label>
|
||||
<select style={inp as any} value={f.cloud_type} onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}>
|
||||
<option value="dropbox">Dropbox</option>
|
||||
<option value="gdrive">Google Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={lbl}>Credentials (JSON veya Token)</label>
|
||||
<textarea style={{ ...inp, resize: 'vertical' } as any} rows={4} 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>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={saveConfig} disabled={saving} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>{saving ? 'Kaydediliyor...' : 'Kaydet'}</button>
|
||||
{config && (
|
||||
<button onClick={deleteConfig} style={{ background: 'rgba(255,0,0,.1)', border: '1px solid rgba(255,0,0,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--red)', cursor: 'pointer', fontSize: 13 }}>Kapat</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 340, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>Geçmiş İşlemler</h3>
|
||||
{logs.length === 0 ? <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Kayıt yok</div> : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{logs.map((log: any) => (
|
||||
<div key={log.id} style={{ padding: 12, background: 'rgba(0,0,0,.2)', borderRadius: 6, borderLeft: `3px solid ${log.status === 'success' ? '#00e676' : 'var(--red)'}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: log.status === 'success' ? '#00e676' : 'var(--red)' }}>{log.status.toUpperCase()}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{new Date(log.ts * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
{log.status === 'success' && <div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Boyut: {(log.file_size / 1024).toFixed(2)} KB</div>}
|
||||
{log.status !== 'success' && <div style={{ fontSize: 11, color: 'var(--red)', fontFamily: 'monospace', marginTop: 4 }}>{log.message}</div>}
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
</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 }))}
|
||||
>
|
||||
<option value="dropbox">Dropbox</option>
|
||||
<option value="gdrive">Google Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{f.cloud_type === 'gdrive' && (
|
||||
<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.
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-80 shrink-0">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-sm">Geçmiş İşlemler</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logs.length === 0 ? <div className="text-muted text-xs font-mono">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 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"/>}
|
||||
{log.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted font-mono">{new Date(log.ts * 1000).toLocaleString()}</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>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityIcon(props: any) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
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'
|
||||
|
||||
type DockerHost = { id: string; name: string; url: string }
|
||||
type Container = {
|
||||
Id: string
|
||||
Names: string[]
|
||||
Image: string
|
||||
State: string
|
||||
Status: string
|
||||
Ports: { PrivatePort: number; PublicPort?: number; Type: string }[]
|
||||
}
|
||||
|
||||
export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[] }) {
|
||||
const [hosts, setHosts] = useState<DockerHost[]>(initialHosts)
|
||||
const [selected, setSelected] = useState<DockerHost | null>(null)
|
||||
const [containers, setContainers] = useState<Container[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
|
||||
const loadHosts = useCallback(async () => {
|
||||
const r = await fetch('/api/config')
|
||||
const cfg = await r.json()
|
||||
if (cfg.docker_hosts) setHosts(cfg.docker_hosts)
|
||||
}, [])
|
||||
|
||||
const select = async (host: DockerHost) => {
|
||||
setSelected(host)
|
||||
setContainers([])
|
||||
setError('')
|
||||
setLoading(true)
|
||||
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ı')
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const deleteHost = async (id: string) => {
|
||||
if (!confirm('Docker bağlantısını sil?')) return
|
||||
await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', id }) })
|
||||
if (selected?.id === id) setSelected(null)
|
||||
loadHosts()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* LEFT */}
|
||||
<div className="w-64 bg-surface border-r border-border flex flex-col shrink-0">
|
||||
<div className="p-4 border-b border-border/50 flex justify-between items-center">
|
||||
<span className="text-xs font-mono text-muted uppercase tracking-widest">Docker Hosts</span>
|
||||
<Button variant="default" size="sm" onClick={() => setShowAdd(true)} className="h-7 text-xs">
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Ekle
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 custom-scrollbar">
|
||||
{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)}
|
||||
className={`p-3 rounded-xl cursor-pointer transition-all border ${
|
||||
selected?.id === host.id
|
||||
? 'bg-accent/5 border-accent/20'
|
||||
: 'border-transparent hover:bg-surface-2 hover:border-border/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Box className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-semibold flex-1 truncate">{host.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); deleteHost(host.id) }} className="text-muted hover:text-destructive transition-colors shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[10px] text-muted font-mono pl-6">
|
||||
<span className="truncate mr-2">{host.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT */}
|
||||
<div className="flex-1 overflow-auto custom-scrollbar relative">
|
||||
{!selected
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted font-mono text-sm opacity-50">
|
||||
<Box className="w-5 h-5 mr-3" />
|
||||
Sol menüden bir Docker sunucusu seçin
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="p-8 max-w-6xl mx-auto fade-up">
|
||||
<div className="mb-8 flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Box className="w-6 h-6 text-accent" />
|
||||
<h2 className="text-2xl font-extrabold tracking-tight">{selected.name}</h2>
|
||||
</div>
|
||||
<div className="text-sm text-muted font-mono bg-surface-2 w-fit px-3 py-1 rounded-md border border-border/50">
|
||||
{selected.url}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => select(selected)} disabled={loading}>
|
||||
<RotateCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Yenile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</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
|
||||
? <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 => (
|
||||
<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>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{containers.map((c, i) => {
|
||||
const name = c.Names?.[0]?.replace(/^\//, '') || 'Bilinmiyor'
|
||||
const isRunning = c.State === 'running'
|
||||
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' : ''}`}>
|
||||
{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' : ''}`}>
|
||||
{c.Image}
|
||||
</td>
|
||||
<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' : ''}`}>
|
||||
<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}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAdd && <AddHostModal onClose={() => setShowAdd(false)} onAdded={() => { loadHosts(); setShowAdd(false) }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddHostModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
|
||||
const [f, setF] = useState({ name: '', url: 'http://' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true); setError('')
|
||||
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) {
|
||||
setError('Bağlantı eklenemedi.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<Card className="w-full max-w-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
|
||||
<CardHeader className="pb-4 border-b border-border/50">
|
||||
<CardTitle>Yeni Docker Host</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Sunucu Adı</label>
|
||||
<Input placeholder="Remote VPS" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Docker API URL</label>
|
||||
<Input placeholder="http://192.168.1.10:2375" value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} />
|
||||
<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>}
|
||||
|
||||
<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">
|
||||
{loading ? 'Ekleniyor...' : 'Ekle'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { readConfig } from '@/lib/config'
|
||||
import ClientPage from './ClientPage'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function DockerPage() {
|
||||
const config = await readConfig()
|
||||
return <ClientPage initialHosts={config.docker_hosts || []} />
|
||||
}
|
||||
+27
-18
@@ -1,5 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: #0d0f14;
|
||||
--color-surface: #13161f;
|
||||
--color-surface-2: #1a1f2e;
|
||||
--color-border: rgba(255, 255, 255, 0.07);
|
||||
--color-accent: #00e5ff;
|
||||
--color-accent-transparent: rgba(0, 229, 255, 0.1);
|
||||
--color-success: #00e676;
|
||||
--color-warning: #ffab00;
|
||||
--color-destructive: #ff3d71;
|
||||
--color-text: #e2e8f0;
|
||||
--color-muted: #8b95a5;
|
||||
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-fade-up: fadeUp 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0d0f14;
|
||||
--surface: #13161f;
|
||||
@@ -11,34 +28,26 @@
|
||||
--red: #ff3d71;
|
||||
--purple: #7b61ff;
|
||||
--text: #e2e8f0;
|
||||
--muted: #4a5270;
|
||||
--muted: #8b95a5;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,229,255,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,229,255,0.02) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
/* Base custom scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #2a3050; border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
|
||||
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
@keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
|
||||
.fade-up { animation: fadeUp .25s ease forwards; }
|
||||
@keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
|
||||
.fade-up { animation: fadeUp .3s ease forwards; }
|
||||
|
||||
+64
-19
@@ -1,6 +1,11 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GridPattern } from '@/components/ui/grid-pattern'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Lock, ArrowRight, XCircle } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
@@ -17,25 +22,65 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 16, padding: '40px 36px', width: 360 }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--accent)', letterSpacing: -0.5 }}>VPS Panel</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>self-hosted ops dashboard</div>
|
||||
</div>
|
||||
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<input
|
||||
type="password" value={pw} onChange={e => setPw(e.target.value)}
|
||||
placeholder="Şifre" autoFocus
|
||||
style={{ background: 'rgba(0,0,0,.3)', border: `1px solid ${err ? 'var(--red)' : 'var(--border)'}`, borderRadius: 8, padding: '11px 14px', color: 'var(--text)', fontSize: 14, fontFamily: 'monospace', outline: 'none', width: '100%' }}
|
||||
/>
|
||||
{err && <div style={{ color: 'var(--red)', fontSize: 12, fontFamily: 'monospace' }}>✕ {err}</div>}
|
||||
<button type="submit" disabled={loading || !pw}
|
||||
style={{ background: 'rgba(0,229,255,.12)', border: '1px solid rgba(0,229,255,.35)', borderRadius: 8, padding: '11px', color: 'var(--accent)', fontSize: 14, fontWeight: 600, cursor: 'pointer', opacity: !pw ? .5 : 1 }}>
|
||||
{loading ? 'Giriş yapılıyor...' : 'Giriş Yap →'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center relative z-10 bg-background overflow-hidden">
|
||||
<GridPattern
|
||||
squares={[
|
||||
[4, 4],
|
||||
[5, 1],
|
||||
[8, 2],
|
||||
[5, 5],
|
||||
[10, 10],
|
||||
[12, 15],
|
||||
[15, 10],
|
||||
[10, 15],
|
||||
]}
|
||||
className="fill-white/[0.01] stroke-white/[0.02]"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
|
||||
<Card className="w-full max-w-[380px] shadow-2xl shadow-accent/5 fade-up border-white/10 bg-surface/80 backdrop-blur-xl relative">
|
||||
<div className="absolute -top-px left-1/2 -translate-x-1/2 w-3/4 h-px bg-gradient-to-r from-transparent via-accent/50 to-transparent" />
|
||||
|
||||
<CardHeader className="text-center pb-8 pt-10">
|
||||
<CardTitle className="text-3xl font-extrabold tracking-tight mb-2">VPS Panel</CardTitle>
|
||||
<CardDescription className="uppercase tracking-[0.15em] text-[10px]">Self-hosted ops dashboard</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={login} className="flex flex-col gap-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||
<Input
|
||||
type="password"
|
||||
value={pw}
|
||||
onChange={e => setPw(e.target.value)}
|
||||
placeholder="Şifre"
|
||||
autoFocus
|
||||
className="pl-10 h-11"
|
||||
/>
|
||||
</div>
|
||||
{err && (
|
||||
<div className="flex items-center gap-2 text-destructive text-xs font-mono bg-destructive/10 p-2 rounded-md border border-destructive/20">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !pw}
|
||||
className="w-full h-11 mt-2 text-[15px] group"
|
||||
>
|
||||
{loading ? 'Giriş yapılıyor...' : (
|
||||
<>
|
||||
Giriş Yap
|
||||
<ArrowRight className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user