first commit

This commit is contained in:
AyrisAI
2026-07-03 11:37:20 +03:00
parent 8cd10d3225
commit 605e49e372
14 changed files with 2712 additions and 1562 deletions
+27 -13
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getAnalyticsStats, getAllDomains, addAnalyticsSite, renameAnalyticsSite, deleteAnalyticsSite } from '@/lib/appDb'
import { getAnalyticsStats, getAllDomains, addAnalyticsSite, renameAnalyticsSite, deleteAnalyticsSite, updateAnalyticsSiteName } from '@/lib/appDb'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
@@ -9,24 +9,29 @@ export async function GET(req: NextRequest) {
const domain = req.nextUrl.searchParams.get('domain')
const days = parseInt(req.nextUrl.searchParams.get('days') ?? '30')
if (!domain) {
// Domain listesi döndür
const domains = await getAllDomains()
return NextResponse.json({ domains })
}
try {
if (!domain) {
// Domain listesi döndür
const domains = await getAllDomains()
return NextResponse.json({ domains })
}
const stats = await getAnalyticsStats(domain, days)
return NextResponse.json(stats)
const stats = await getAnalyticsStats(domain, days)
return NextResponse.json(stats)
} catch (error: any) {
console.error('GET stats error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { domain } = await req.json()
const { domain, name } = await req.json()
if (!domain) return NextResponse.json({ error: 'Domain required' }, { status: 400 })
await addAnalyticsSite(domain)
await addAnalyticsSite(domain, name)
return NextResponse.json({ ok: true })
}
@@ -34,10 +39,19 @@ export async function PUT(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { oldDomain, newDomain } = await req.json()
if (!oldDomain || !newDomain) return NextResponse.json({ error: 'Missing params' }, { status: 400 })
const { oldDomain, newDomain, name } = await req.json()
if (oldDomain && newDomain) {
await renameAnalyticsSite(oldDomain, newDomain)
if (name !== undefined) {
await updateAnalyticsSiteName(newDomain, name)
}
} else if (oldDomain && name !== undefined) {
await updateAnalyticsSiteName(oldDomain, name)
} else {
return NextResponse.json({ error: 'Missing params' }, { status: 400 })
}
await renameAnalyticsSite(oldDomain, newDomain)
return NextResponse.json({ ok: true })
}
+9 -3
View File
@@ -66,15 +66,21 @@ export async function POST(req: NextRequest) {
}
}
// Delete backup config
// Delete backup config or backup log
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
if (!db_id) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
const { db_id, logId } = body
if (logId) {
await pool.query(`DELETE FROM backup_logs WHERE id = $1`, [logId])
return NextResponse.json({ ok: true })
}
if (!db_id) return NextResponse.json({ error: 'Missing db_id or logId' }, { status: 400 })
await pool.query(`DELETE FROM backup_configs WHERE db_id = $1`, [db_id])
await reloadBackupCrons()
+7 -3
View File
@@ -33,6 +33,7 @@ export async function POST(req: NextRequest) {
const filename = type === 'gdrive' ? backupFilename() : backupFilename(db.name)
const backupStart = Date.now()
try {
const buffer = await createDbDumpBuffer(db)
@@ -46,9 +47,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
}
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db_id, 'success', `Manuel yedek — ${type}`, buffer.length]
`INSERT INTO backup_logs (db_id, status, message, file_size, duration_sec) VALUES ($1, $2, $3, $4, $5)`,
[db_id, 'success', `Manuel yedek — ${type}`, buffer.length, durationSec]
)
const sizeStr = buffer.length > 1024 * 1024
@@ -58,7 +61,8 @@ export async function POST(req: NextRequest) {
const pathStr = type === 'gdrive' ? `${db.name}/${filename}` : filename
return NextResponse.json({ ok: true, detail: `✓ Yüklendi — ${pathStr} (${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(() => {})
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query(`INSERT INTO backup_logs (db_id, status, message, file_size, duration_sec) VALUES ($1, $2, $3, $4, $5)`, [db_id, 'error', e.message, 0, durationSec]).catch(() => {})
return NextResponse.json({ ok: false, error: e.message })
}
}
+7 -4
View File
@@ -17,14 +17,16 @@ export async function GET(req: NextRequest) {
const db = config.databases.find(d => d.id === dbId)
if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
const backupStart = Date.now()
try {
const buffer = await createDbDumpBuffer(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql`
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[dbId, 'success', 'Manuel indirme', buffer.length]
`INSERT INTO backup_logs (db_id, status, message, file_size, duration_sec) VALUES ($1, $2, $3, $4, $5)`,
[dbId, 'success', 'Manuel indirme', buffer.length, durationSec]
)
return new NextResponse(new Uint8Array(buffer), {
@@ -34,9 +36,10 @@ export async function GET(req: NextRequest) {
},
})
} catch (e: any) {
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[dbId, 'error', e.message, 0]
`INSERT INTO backup_logs (db_id, status, message, file_size, duration_sec) VALUES ($1, $2, $3, $4, $5)`,
[dbId, 'error', e.message, 0, durationSec]
)
return NextResponse.json({ error: e.message }, { status: 500 })
}
+421 -292
View File
@@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
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'
import { Edit2, Trash2, Plus, Code, Globe, CalendarDays, LineChart, CheckCircle2, Copy, Loader2, ArrowUpRight } from 'lucide-react'
type Stats = {
total: number
@@ -25,25 +25,27 @@ const DAYS_OPTIONS = [
{ id: '90', label: '90G' },
]
function Bar({ label, value, max, color = 'var(--color-accent)' }: { label: string; value: number; max: number; color?: string }) {
function Bar({ label, value, max, color = '#00e5ff' }: { label: string; value: number; max: number; color?: string }) {
const pct = max > 0 ? (value / max) * 100 : 0
return (
<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 className="mb-4">
<div className="flex justify-between mb-1.5 font-mono text-xs">
<span className="text-[#e2e8f0]/90 truncate flex-1 mr-2">{label}</span>
<span className="text-muted shrink-0 font-bold">{value.toLocaleString()}</span>
</div>
<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 className="h-2 bg-black/30 rounded-full overflow-hidden border border-white/[0.02]">
<div
className="h-full rounded-full transition-all duration-500 ease-out"
style={{ width: `${pct}%`, backgroundColor: color, boxShadow: `0 0 6px ${color}40` }}
/>
</div>
</div>
)
}
function MiniChart({ data, days }: { data: { day: string; views: number; visitors: number }[]; days: number }) {
if (!data.length) return <div className="h-20 flex items-center justify-center text-muted text-xs font-mono">Henüz veri yok</div>
if (!data.length) return <div className="h-24 flex items-center justify-center text-muted text-xs font-mono">No data collected yet</div>
// Tüm günleri doldur
const filled: { day: string; views: number; visitors: number }[] = []
const now = new Date()
for (let i = days - 1; i >= 0; i--) {
@@ -57,17 +59,23 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
const maxViews = Math.max(...filled.map(d => d.views), 1)
return (
<div className="flex items-end gap-1 h-20 px-1">
<div className="flex items-end gap-1.5 h-24 px-1">
{filled.map((d, i) => (
<div
key={i}
className="flex-1 flex flex-col items-center gap-[1px] h-full justify-end"
className="flex-1 flex flex-col items-center gap-[1px] h-full justify-end group relative"
>
<div
className="w-full bg-accent/80 rounded-t-sm transition-all duration-300 ease-out"
className="absolute bottom-full mb-1 bg-[#131926] border border-[#1d2639] text-[10px] font-mono font-bold text-white px-2 py-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 whitespace-nowrap"
>
{d.day}: {d.views} views
</div>
<div
className="w-full bg-[#00e5ff] hover:bg-white rounded-t 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
height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 5 : 0)}%`,
minHeight: d.views > 0 ? 2 : 0,
boxShadow: d.views > 0 ? '0 0 8px rgba(0,229,255,0.2)' : 'none'
}}
/>
</div>
@@ -77,30 +85,42 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
}
export default function AnalyticsPage() {
const [domains, setDomains] = useState<string[]>([])
const [domains, setDomains] = useState<{ domain: string; name: string | null }[]>([])
const [selectedDomain, setSelectedDomain] = useState<string | null>(null)
const [days, setDays] = useState('30')
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(false)
const [showSnippet, setShowSnippet] = useState(false)
const [showAddSite, setShowAddSite] = useState(false)
const [newDomain, setNewDomain] = useState('')
const [editingSite, setEditingSite] = useState<{ domain: string; name: string | null } | null>(null)
const [panelUrl, setPanelUrl] = useState('')
const [copied, setCopied] = useState(false)
const loadDomains = useCallback(() => {
fetch('/api/analytics/stats')
.then(r => r.json())
.then(async r => {
if (!r.ok) {
const errData = await r.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${r.status}`);
}
return r.json()
})
.then(d => {
const dms = d.domains ?? []
setDomains(dms)
if (dms.length > 0) {
if (!selectedDomain || !dms.includes(selectedDomain)) {
setSelectedDomain(dms[0])
// Check if we still have the selected domain
const match = dms.find((x: any) => x.domain === selectedDomain)
if (!selectedDomain || !match) {
setSelectedDomain(dms[0].domain)
}
} else {
setSelectedDomain(null)
}
})
.catch(err => {
console.error('Error loading domains:', err)
})
}, [selectedDomain])
useEffect(() => {
@@ -111,36 +131,27 @@ export default function AnalyticsPage() {
const loadStats = useCallback(async () => {
if (!selectedDomain) return
setLoading(true)
const r = await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}&days=${days}`)
setStats(await r.json())
try {
const r = await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}&days=${days}`)
if (!r.ok) {
const errData = await r.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${r.status}`);
}
setStats(await r.json())
} catch (err) {
console.error('Error loading stats:', err)
}
setLoading(false)
}, [selectedDomain, days])
useEffect(() => { loadStats() }, [loadStats])
const handleRename = async () => {
if (!selectedDomain) return
const newName = window.prompt("Yeni alan adını girin (Örn: example.com):", selectedDomain)
if (!newName || newName.trim() === '' || newName === selectedDomain) return
setLoading(true)
await fetch('/api/analytics/stats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldDomain: selectedDomain, newDomain: newName.trim() })
})
setSelectedDomain(newName.trim())
setTimeout(loadDomains, 100)
}
const handleDelete = async () => {
if (!selectedDomain) return
if (!window.confirm(`'${selectedDomain}' sitesini ve tüm analiz verilerini SİLMEK istediğinize emin misiniz? Bu işlem geri alınamaz!`)) return
const handleDelete = async (dName: string) => {
if (!window.confirm(`'${dName}' sitesini ve tüm analiz verilerini SİLMEK istediğinize emin misiniz? Bu işlem geri alınamaz!`)) return
setLoading(true)
await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}`, { method: 'DELETE' })
setSelectedDomain(null)
await fetch(`/api/analytics/stats?domain=${encodeURIComponent(dName)}`, { method: 'DELETE' })
if (selectedDomain === dName) setSelectedDomain(null)
setTimeout(loadDomains, 100)
}
@@ -148,212 +159,290 @@ export default function AnalyticsPage() {
const snippet = `<!-- VPS Panel Analytics -->
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
const selectedSite = domains.find(d => d.domain === selectedDomain)
return (
<div className="p-8 max-w-[1200px] mx-auto fade-up">
{/* Header */}
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-8">
<div>
<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 className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* Analyticus Top Header Bar */}
<header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 text-accent bg-accent/10 border border-accent/20 rounded-lg flex items-center justify-center p-1.5 shadow-[0_0_12px_rgba(0,229,255,0.1)]">
<LineChart className="w-full h-full text-accent" />
</div>
<span className="font-extrabold text-base tracking-tight text-white select-none">Analyticus</span>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Domain Actions */}
{selectedDomain && (
<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 className="flex items-center gap-3">
<Button
variant="default"
size="sm"
onClick={() => setShowSnippet(true)}
className="h-8 bg-purple-500/15 text-purple-400 border border-purple-500/30 hover:bg-purple-500/25 px-3 font-semibold text-xs gap-1.5"
>
<Code className="w-3.5 h-3.5" /> Snippet
</Button>
<span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
Community
</span>
</div>
</header>
{/* Main Split Layout */}
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar: Domains selector list */}
<aside className="w-80 bg-[#0b0f19] border-r border-border flex flex-col shrink-0">
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{domains.length === 0 ? (
<div className="py-8 text-center text-muted font-mono text-xs">
No sites monitored
</div>
) : (
domains.map(d => {
const isSelected = selectedDomain === d.domain
return (
<div
key={d.domain}
onClick={() => setSelectedDomain(d.domain)}
className={`p-4 rounded-xl cursor-pointer transition-all duration-200 border relative group ${
isSelected
? 'bg-[#141b2a] border-accent/60 shadow-[0_0_15px_rgba(0,229,255,0.04)] ring-1 ring-accent/20'
: 'bg-[#131926] border-[#1d2639] hover:border-accent/40'
}`}
>
<div className="flex items-center gap-2 pr-12">
<Globe className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="text-sm font-bold text-white truncate tracking-tight">
{d.name || d.domain}
</span>
</div>
{d.name && (
<div className="text-[10px] text-muted/65 font-mono truncate mt-1 pl-5.5">
{d.domain}
</div>
)}
{/* Floating controls on hover */}
<div className="absolute top-3.5 right-3.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
<button
onClick={e => { e.stopPropagation(); setEditingSite(d) }}
className="p-1 bg-[#1a1f2e] hover:bg-[#252c3e] text-muted hover:text-white rounded border border-[#1d2639]"
title="Düzenle"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={e => { e.stopPropagation(); handleDelete(d.domain) }}
className="p-1 bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white rounded border border-red-500/20"
title="Sil"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})
)}
</div>
{/* Add Site Button */}
<div className="p-4 border-t border-border bg-[#0a0d16] shrink-0">
<Button
onClick={() => setShowAddSite(true)}
className="w-full bg-[#1a56db] hover:bg-[#1a56db]/90 text-white font-semibold py-2.5 rounded-xl border border-transparent transition-all duration-200 flex items-center justify-center gap-1.5 shadow-md text-xs uppercase tracking-wider"
>
<Plus className="w-4 h-4" /> Add Site
</Button>
</div>
</aside>
{/* Right Panel: Domain Analytics Detail */}
<main className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#070a13] relative">
{!selectedDomain ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted font-mono text-sm p-4 text-center">
<Globe className="w-10 h-10 mb-4 text-muted/40 animate-pulse" />
<div className="max-w-xs text-xs font-semibold leading-relaxed">
Sol menüden bir alan adı seçin veya yeni bir alan adı ekleyerek analiz metriklerini görüntüleyin.
</div>
</div>
) : (
<div className="max-w-5xl mx-auto fade-up space-y-6">
{/* Domain detail header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-border/40 pb-5">
<div>
<h2 className="text-2xl font-extrabold text-white tracking-tight mb-1">
{selectedSite?.name || selectedDomain}
</h2>
<a href={`https://${selectedDomain}`} target="_blank" rel="noreferrer" className="text-xs text-accent hover:underline font-mono flex items-center gap-1">
https://{selectedDomain} <ArrowUpRight className="w-3.5 h-3.5" />
</a>
</div>
<div className="flex items-center gap-3">
{/* Days tab filters */}
<div className="bg-[#0b0f19] border border-border/80 p-0.5 rounded-lg">
<AnimatedTabs
activeTab={days}
onChange={(id) => setDays(id)}
tabs={DAYS_OPTIONS}
className="border-0 bg-transparent p-0"
/>
</div>
</div>
</div>
{stats && !loading ? (
<div className="space-y-6 fade-up">
{/* Metrics cards row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ label: 'Toplam Görüntüleme', value: stats.total.toLocaleString(), color: 'text-accent' },
{ label: 'Tekil Ziyaretçi', value: stats.unique.toLocaleString(), color: 'text-[#00e676]' },
].map(s => (
<Card key={s.label} className="bg-[#0b0f19] border-border/80 shadow">
<CardContent className="p-6">
<div className="text-[10px] text-muted font-mono uppercase tracking-wider mb-2">{s.label}</div>
<div className={`text-4xl font-extrabold tracking-tighter ${s.color}`}>{s.value}</div>
<div className="text-[10px] text-muted/65 font-mono mt-3.5 flex items-center gap-1.5">
<CalendarDays className="w-3.5 h-3.5" /> son {days} gün
</div>
</CardContent>
</Card>
))}
</div>
{/* Daily Chart */}
<Card className="bg-[#0b0f19] border-border/80 shadow">
<CardHeader className="pb-3 border-b border-border/30">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-wider">Günlük Görüntülemeler</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<MiniChart data={stats.daily} days={parseInt(days)} />
<div className="flex justify-between mt-3 text-[9px] font-mono text-muted/60">
<span>{days} gün önce</span>
<span>bugün</span>
</div>
</CardContent>
</Card>
{/* 3-column analytics progress breakdown */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Top pages */}
<Card className="bg-[#0b0f19] border-border/80 shadow">
<CardHeader className="pb-3 border-b border-border/30 mb-4">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-wider">Sayfalar</CardTitle>
</CardHeader>
<CardContent>
{stats.topPages.length === 0 ? (
<div className="text-xs text-muted font-mono py-4 text-center">Veri yok</div>
) : (
stats.topPages.map(p => <Bar key={p.path} label={p.path || '/'} value={p.views} max={stats.topPages[0]?.views ?? 1} color="#00e5ff" />)
)}
</CardContent>
</Card>
{/* Traffic Referrers */}
<Card className="bg-[#0b0f19] border-border/80 shadow">
<CardHeader className="pb-3 border-b border-border/30 mb-4">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-wider">Trafik Kaynağı</CardTitle>
</CardHeader>
<CardContent>
{stats.topReferrers.length === 0 ? (
<div className="text-xs text-muted font-mono py-4 text-center">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="#00e676" />
})
)}
</CardContent>
</Card>
{/* Countries list */}
<Card className="bg-[#0b0f19] border-border/80 shadow">
<CardHeader className="pb-3 border-b border-border/30 mb-4">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-wider">Ülkeler</CardTitle>
</CardHeader>
<CardContent>
{stats.byCountry.length === 0 ? (
<div className="text-xs text-muted font-mono py-4 text-center">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="#ffab00" />)
)}
</CardContent>
</Card>
</div>
{/* Device / Browser / OS breakdown */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ title: 'Cihaz', data: stats.byDevice.map(d => ({ label: d.device, value: d.n })), color: '#7b61ff' },
{ title: 'Tarayıcı', data: stats.byBrowser.map(d => ({ label: d.browser, value: d.n })), color: '#7b61ff' },
{ title: 'İşletim Sistemi', data: stats.byOs.map(d => ({ label: d.os, value: d.n })), color: '#7b61ff' },
].map(section => (
<Card key={section.title} className="bg-[#0b0f19] border-border/80 shadow">
<CardHeader className="pb-3 border-b border-border/30 mb-4">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-wider">{section.title}</CardTitle>
</CardHeader>
<CardContent>
{section.data.length === 0 ? (
<div className="text-xs text-muted font-mono py-4 text-center">Veri yok</div>
) : (
section.data.map(d => <Bar key={d.label} label={d.label} value={d.value} max={section.data[0]?.value ?? 1} color={section.color} />)
)}
</CardContent>
</Card>
))}
</div>
</div>
) : null}
{loading && (
<div className="py-24 text-center text-muted font-mono text-xs animate-pulse flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span>Veriler yükleniyor...</span>
</div>
)}
</div>
)}
<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>
</main>
</div>
{/* Domain tabs */}
{domains.length > 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)}
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>
) : (
<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 className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ 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 => (
<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 */}
<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 className="grid grid-cols-3 gap-6">
{/* Top pages */}
<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 */}
<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 */}
<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 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 => (
<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 className="py-20 text-center text-muted font-mono text-sm animate-pulse">Yükleniyor...</div>
)}
{/* Snippet modal */}
{showSnippet && (
<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:
<div className="fixed inset-0 bg-black/75 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 bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/80">
<CardTitle className="text-white text-lg font-bold">Tracking Snippet</CardTitle>
<CardDescription className="text-muted/80">
Sitendeki <code className="bg-black/30 px-1.5 py-0.5 rounded border border-border/50 text-accent">{'<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>
<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>
<CardContent className="pt-6 space-y-4">
<div className="relative group">
<pre className="bg-black/40 border border-[#1d2639] rounded-xl p-4 font-mono text-[11px] text-accent overflow-x-auto whitespace-pre-wrap leading-relaxed shadow-inner">
{snippet}
</pre>
</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
<div className="space-y-2 text-xs text-muted/70 font-mono bg-black/10 p-3 rounded-lg border border-border/50">
<div><span className="text-accent font-bold">data-domain</span> hangi alan adı olduğunu belirtir, değiştirmeyin.</div>
<div><span className="text-accent font-bold">defer</span> sayfa yüklenmesini yavaşlatmaz, asenkron yüklenir.</div>
</div>
<div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={() => setShowSnippet(false)} className="flex-1 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]">Kapat</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(snippet)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className={`flex-1 font-bold text-xs uppercase tracking-wider ${copied ? 'bg-emerald-500 hover:bg-emerald-600 text-white' : 'bg-accent hover:bg-accent/90 text-[#0d0f14]'}`}
>
{copied ? '✓ Kopyalandı' : 'Kopyala'}
</Button>
</div>
</CardContent>
@@ -361,63 +450,103 @@ export default function AnalyticsPage() {
</div>
)}
{/* Add Site modal */}
{showAddSite && (
<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());
})
}
}}
/>
<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>
{/* Add / Edit Site modal */}
{(showAddSite || editingSite) && (
<AddSiteModal
site={editingSite ?? undefined}
onClose={() => { setShowAddSite(false); setEditingSite(null) }}
onSaved={() => { loadDomains(); setShowAddSite(false); setEditingSite(null) }}
/>
)}
</div>
)
}
function AddSiteModal({
site,
onClose,
onSaved
}: {
site?: { domain: string; name: string | null };
onClose: () => void;
onSaved: () => void
}) {
const [domain, setDomain] = useState(site?.domain || '')
const [name, setName] = useState(site?.name || '')
const [loading, setLoading] = useState(false)
const submit = async () => {
setLoading(true)
if (site) {
// Editing
await fetch('/api/analytics/stats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldDomain: site.domain,
newDomain: domain.trim(),
name: name.trim() || null
})
})
} else {
// Adding
await fetch('/api/analytics/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: domain.trim(),
name: name.trim() || null
})
})
}
setLoading(false)
onSaved()
}
return (
<div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[420px] shadow-2xl animate-in fade-in zoom-in-95 duration-200 bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/80">
<CardTitle className="text-white text-lg font-bold">{site ? 'Site Düzenle' : 'Yeni Site Ekle'}</CardTitle>
<CardDescription className="text-muted/80">
{site ? 'Sitenin alan adını veya görünen ismini güncelleyin.' : 'Takip etmek istediğiniz web sitesinin detaylarını 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">Site İsmi (Görünen İsim)</label>
<Input
placeholder="Örn: Kişisel Blog"
value={name}
onChange={e => setName(e.target.value)}
className="bg-black/20 border-[#1d2639] focus-visible:ring-accent/50 text-sm"
autoFocus={!!site}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Alan Adı (Domain)</label>
<Input
placeholder="example.com"
value={domain}
onChange={e => setDomain(e.target.value)}
className="bg-black/20 border-[#1d2639] focus-visible:ring-accent/50 font-mono text-sm"
autoFocus={!site}
/>
</div>
<div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]">İptal</Button>
<Button
disabled={loading || !domain.trim()}
onClick={submit}
className="flex-1 bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold"
>
{loading ? 'Yükleniyor...' : site ? 'Güncelle' : 'Ekle'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+172 -98
View File
@@ -1,13 +1,13 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, CheckCircle2, Link2, RefreshCw, ShieldAlert, Copy } from 'lucide-react'
import { Plus, Trash2, CheckCircle2, Link2, RefreshCw, ShieldAlert, Copy, Cloud, Edit2 } from 'lucide-react'
type Conn = { id: string; name: string; type: 'gcs' | 'gdrive' | 'dropbox'; credentials: string; default_target: string | null }
const TYPE_LABELS: Record<string, string> = { gcs: '☁️ Google Cloud Storage', gdrive: '📁 Google Drive', dropbox: '📦 Dropbox' }
const TYPE_LABELS: Record<string, string> = { gcs: 'Google Cloud Storage', gdrive: 'Google Drive', dropbox: 'Dropbox' }
function isGdriveConnected(credentials: string) {
try { return !!JSON.parse(credentials).refresh_token } catch { return false }
@@ -42,80 +42,140 @@ export default function ConnectionsPage() {
}, [load])
const del = async (id: string) => {
if (!confirm('Bu bağlantıyı sil?')) return
if (!confirm('Bu bağlantıyı silmek istediğinize emin misiniz?')) return
await fetch(`/api/cloud-connections?id=${id}`, { method: 'DELETE' })
load()
}
return (
<div style={{ padding: 28, maxWidth: 900 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Cloud Bağlantıları</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Bir kere kur, tüm DB yedekleri için kullan</p>
<div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* Connectus Top Header Bar */}
<header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 text-accent bg-accent/10 border border-accent/20 rounded-lg flex items-center justify-center p-1.5 shadow-[0_0_12px_rgba(0,229,255,0.1)]">
<Cloud className="w-full h-full text-accent" />
</div>
<span className="font-extrabold text-base tracking-tight text-white select-none">Connectus</span>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setShowAdd(true)}
className="h-8 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0] px-3 font-semibold text-xs gap-1.5"
>
<Plus className="w-3.5 h-3.5" /> Yeni Bağlantı
</Button>
<span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
Community
</span>
</div>
</header>
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#070a13]">
<div className="max-w-4xl mx-auto space-y-6 fade-up">
{/* Subtitle description */}
<div>
<p className="text-xs text-muted/80 font-mono bg-[#0b0f19] border border-border/80 w-fit px-3 py-1 rounded-md">
Bir kere bulut entegrasyonu kurun, tüm veritabanı yedekleriniz için hedefler olarak kullanın
</p>
</div>
{/* Success/Error alert notice */}
{notice && (
<div className={`flex items-center gap-2.5 rounded-xl border px-4 py-3.5 text-xs font-mono shadow ${notice.ok ? 'border-emerald-500/30 bg-emerald-500/5 text-[#00e676]' : 'border-red-500/30 bg-red-500/5 text-[#ff3d71]'}`}>
{notice.ok ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <ShieldAlert className="w-4 h-4 shrink-0" />}
<span>{notice.msg}</span>
<button onClick={() => setNotice(null)} className="ml-auto opacity-60 hover:opacity-100 font-bold"></button>
</div>
)}
{/* Connections list */}
{conns.length === 0 ? (
<Card className="border-dashed border-2 bg-[#0b0f19]/30 border-border/80 py-16">
<CardContent className="flex flex-col items-center justify-center text-center">
<Link2 className="w-12 h-12 text-muted/30 mb-4 animate-pulse" />
<h3 className="text-sm font-bold text-white mb-1.5 select-none">Henüz bağlantı bulunmuyor</h3>
<p className="text-xs text-muted/80 mb-5 max-w-sm">
Dropbox, Google Drive veya Google Cloud Storage entegrasyonlarınızı buradan yapılandırın.
</p>
<Button onClick={() => setShowAdd(true)} className="bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold">
<Plus className="w-4 h-4 mr-1.5" /> Bağlantı Ekle
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{conns.map(conn => {
const gdriveOk = conn.type === 'gdrive' && isGdriveConnected(conn.credentials)
// Set custom icon prefix
let iconPrefix = '☁️'
if (conn.type === 'gdrive') iconPrefix = '📁'
if (conn.type === 'dropbox') iconPrefix = '📦'
return (
<Card key={conn.id} className="bg-[#0b0f19] border-border/80 shadow-md">
<CardContent className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5 mb-2">
<span className="font-extrabold text-sm text-white tracking-tight">{conn.name}</span>
<span className="text-[10px] font-mono font-bold bg-black/30 border border-border px-2 py-0.5 rounded-md text-muted select-none">
{iconPrefix} {TYPE_LABELS[conn.type] ?? conn.type}
</span>
{conn.type === 'gdrive' && (
<span className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded-md border select-none ${gdriveOk ? 'bg-emerald-500/10 border-emerald-500/20 text-[#00e676]' : 'bg-[#ffab00]/10 border-[#ffab00]/20 text-[#ffab00]'}`}>
{gdriveOk ? '✓ Bağlı' : '⚠ Yetki Yok'}
</span>
)}
</div>
<div className="text-[11px] text-muted/65 font-mono">
{conn.default_target ? `Varsayılan Hedef: ${conn.default_target}` : 'Varsayılan klasör/bucket hedeflenmedi'}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
{conn.type === 'gdrive' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]"
onClick={() => { window.location.href = `/api/cloud-connections/gdrive?connection_id=${conn.id}` }}
>
<RefreshCw className="w-3.5 h-3.5" />
{gdriveOk ? 'Yenile' : 'Bağlan'}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 text-xs font-bold border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]"
onClick={() => setEditConn(conn)}
>
Düzenle
</Button>
<button
onClick={() => del(conn.id)}
className="p-2 text-muted hover:text-red-500 hover:bg-red-500/15 rounded border border-transparent transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
<Button onClick={() => setShowAdd(true)} className="gap-1.5"><Plus className="w-4 h-4" /> Yeni Bağlantı</Button>
</div>
{notice && (
<div className={`flex items-center gap-2.5 rounded-xl border px-4 py-3 mb-6 text-sm font-mono ${notice.ok ? 'border-success/30 bg-success/5 text-success' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
{notice.ok ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <ShieldAlert className="w-4 h-4 shrink-0" />}
{notice.msg}
<button onClick={() => setNotice(null)} className="ml-auto opacity-60 hover:opacity-100"></button>
</div>
)}
{conns.length === 0 ? (
<Card className="border-dashed border-2 bg-surface/30">
<CardContent className="flex flex-col items-center py-16 text-center">
<Link2 className="w-10 h-10 text-muted/30 mb-4" />
<div className="text-sm font-semibold mb-2">Henüz bağlantı yok</div>
<p className="text-xs text-muted mb-5">GCS, Google Drive veya Dropbox bağlantısı ekle.</p>
<Button onClick={() => setShowAdd(true)}><Plus className="w-4 h-4 mr-1.5" /> Ekle</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{conns.map(conn => {
const gdriveOk = conn.type === 'gdrive' && isGdriveConnected(conn.credentials)
return (
<Card key={conn.id} className="bg-surface/50">
<CardContent className="p-5 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<span className="font-semibold text-sm">{conn.name}</span>
<span className="text-[10px] font-mono bg-white/5 border border-border/50 px-2 py-0.5 rounded-full text-muted">
{TYPE_LABELS[conn.type] ?? conn.type}
</span>
{conn.type === 'gdrive' && (
<span className={`text-[10px] font-mono px-2 py-0.5 rounded-full border ${gdriveOk ? 'bg-success/10 border-success/30 text-success' : 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'}`}>
{gdriveOk ? '✓ Bağlı' : '⚠ Yetki Yok'}
</span>
)}
</div>
<div className="text-[11px] text-muted font-mono">
{conn.default_target ? `Hedef: ${conn.default_target}` : 'Varsayılan hedef yok'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{conn.type === 'gdrive' && (
<Button variant="outline" size="sm" className="h-8 text-xs gap-1.5"
onClick={() => { window.location.href = `/api/cloud-connections/gdrive?connection_id=${conn.id}` }}>
<RefreshCw className="w-3 h-3" /> {gdriveOk ? 'Yenile' : 'Bağlan'}
</Button>
)}
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setEditConn(conn)}>Düzenle</Button>
<button onClick={() => del(conn.id)} className="p-1.5 text-muted hover:text-destructive transition-colors rounded">
<Trash2 className="w-4 h-4" />
</button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* Add / Edit Connection Modal overlay */}
{(showAdd || editConn) && (
<ConnModal
conn={editConn}
@@ -152,89 +212,103 @@ function ConnModal({ conn, onClose, onSaved }: { conn: Conn | null; onClose: ()
}
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[520px] shadow-2xl" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/50">
<CardTitle>{conn ? 'Bağlantı Düzenle' : 'Yeni Cloud Bağlantısı'}</CardTitle>
<div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[530px] shadow-2xl bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/80">
<CardTitle className="text-white text-lg font-bold">{conn ? 'Bağlantı Düzenle' : 'Yeni Cloud Bağlantısı'}</CardTitle>
<CardDescription className="text-muted/80">Yedek hedefleri olarak kullanabileceğiniz hesap yetkilendirmelerini girin.</CardDescription>
</CardHeader>
<CardContent className="pt-5 space-y-4">
<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ı Adı</label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="Production Drive, Backup GCS…" />
<Input value={name} onChange={e => setName(e.target.value)} placeholder="Örn: Production Google Drive" className="bg-black/20 border-[#1d2639]" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Tür</label>
<div className="flex gap-2">
{(['gcs', 'gdrive', 'dropbox'] as const).map(t => (
<button key={t} onClick={() => setType(t)}
className={`flex-1 py-2 rounded-lg border text-xs font-medium transition-all ${type === t ? 'bg-accent/10 border-accent/40 text-accent' : 'border-border/50 text-muted hover:text-text hover:border-border'}`}>
<button
key={t}
onClick={() => setType(t)}
className={`flex-1 py-2.5 rounded-lg border text-xs font-bold transition-all ${type === t ? 'bg-accent/10 border-accent/25 text-accent shadow-sm' : 'bg-black/20 border-[#1d2639] text-muted hover:text-white'}`}
>
{TYPE_LABELS[t]}
</button>
))}
</div>
</div>
{/* GCS */}
{/* GCS Fields */}
{type === 'gcs' && (
<>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Service Account JSON</label>
<textarea className="flex w-full rounded-md border border-border bg-black/30 px-3 py-2 text-xs font-mono resize-y min-h-[90px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" value={credentials} onChange={e => setCredentials(e.target.value)} placeholder={'{\n "type": "service_account",\n ...\n}'} />
<textarea
className="flex w-full rounded-lg border border-[#1d2639] bg-black/20 px-3 py-2 text-xs font-mono resize-y min-h-[100px] text-[#e2e8f0] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent"
value={credentials}
onChange={e => setCredentials(e.target.value)}
placeholder={'{\n "type": "service_account",\n "project_id": ...\n}'}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Varsayılan Bucket</label>
<Input value={defaultTarget} onChange={e => setDefaultTarget(e.target.value)} placeholder="my-backups-bucket" />
<Input value={defaultTarget} onChange={e => setDefaultTarget(e.target.value)} placeholder="my-backups-bucket" className="bg-black/20 border-[#1d2639]" />
</div>
</>
)}
{/* Dropbox */}
{/* Dropbox Fields */}
{type === 'dropbox' && (
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Access Token</label>
<Input value={credentials} onChange={e => setCredentials(e.target.value)} placeholder="sl.xxxxxxxxxxxxxxxx..." />
<div className="text-[10px] text-muted/60 font-mono">dropbox.com/developers Apps Generate token</div>
<Input value={credentials} onChange={e => setCredentials(e.target.value)} placeholder="sl.xxxxxxxxxxxxxxxx..." className="bg-black/20 border-[#1d2639] font-mono text-sm" />
<div className="text-[9px] text-muted/65 font-mono mt-1">
dropbox.com/developers Apps Generate token linkinden geçici token alın.
</div>
</div>
)}
{/* Google Drive */}
{/* Google Drive Fields */}
{type === 'gdrive' && (
<>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Authorized Redirect URI (Google Console'a ekle)</label>
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Authorized Redirect URI (Google Console'a ekleyin)</label>
<div className="flex items-center gap-2">
<div className="flex-1 rounded-md border border-accent/30 bg-accent/5 px-3 py-2 text-xs font-mono text-accent truncate">{redirectUri}</div>
<button onClick={() => { navigator.clipboard.writeText(redirectUri); setCopiedUri(true); setTimeout(() => setCopiedUri(false), 2000) }}
className="p-2 rounded border border-border hover:bg-surface-2 text-muted hover:text-text shrink-0 transition-colors">
{copiedUri ? <CheckCircle2 className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
<div className="flex-1 rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5 text-xs font-mono text-accent truncate">{redirectUri}</div>
<button
onClick={() => { navigator.clipboard.writeText(redirectUri); setCopiedUri(true); setTimeout(() => setCopiedUri(false), 2000) }}
className="p-2.5 rounded-lg border border-[#1d2639] hover:bg-[#1a1f2e] text-muted hover:text-white shrink-0 transition-colors"
>
{copiedUri ? <CheckCircle2 className="w-3.5 h-3.5 text-[#00e676]" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client ID</label>
<Input value={clientId} onChange={e => setClientId(e.target.value)} placeholder="xxxxxxx.apps.googleusercontent.com" />
<Input value={clientId} onChange={e => setClientId(e.target.value)} placeholder="xxxxxxx.apps.googleusercontent.com" className="bg-black/20 border-[#1d2639] font-mono text-sm" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client Secret</label>
<Input type="password" value={clientSecret} onChange={e => setClientSecret(e.target.value)} placeholder="GOCSPX-..." />
<Input type="password" value={clientSecret} onChange={e => setClientSecret(e.target.value)} placeholder="GOCSPX-..." className="bg-black/20 border-[#1d2639]" />
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Varsayılan Klasör ID (Opsiyonel)</label>
<Input value={defaultTarget} onChange={e => setDefaultTarget(e.target.value)} placeholder="1A2b3C4d5E6f7G8h9I0j..." />
<Input value={defaultTarget} onChange={e => setDefaultTarget(e.target.value)} placeholder="Klasörün URL adresindeki uzun karakter dizisi" className="bg-black/20 border-[#1d2639]" />
</div>
<div className="rounded-lg bg-yellow-500/5 border border-yellow-500/20 px-3 py-2.5 text-[11px] font-mono text-yellow-200/70">
Kaydet → ardından "Bağlan" butonuyla Google hesabını yetkilendir.
<div className="rounded-lg bg-[#ffab00]/5 border border-[#ffab00]/20 px-3.5 py-3 text-[11px] font-mono text-[#ffab00]/70 leading-normal">
İlk önce buradaki bilgileri kaydedin → ardından dışarıdaki "Bağlan" butonu ile Google hesabınızı yetkilendirin.
</div>
</>
)}
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button>
<Button onClick={submit} disabled={saving || !name} className="flex-1">
{saving ? 'Kaydediliyor...' : conn ? 'Güncelle' : 'Kaydet'}
<div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]">İptal</Button>
<Button onClick={submit} disabled={saving || !name} className="flex-1 bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold">
{saving ? 'Kaydediliyor...' : conn ? 'Kaydet' : 'Kaydet'}
</Button>
</div>
</CardContent>
File diff suppressed because it is too large Load Diff
+259 -177
View File
@@ -1,9 +1,12 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, X, Box, ShieldAlert, RotateCw, Play, Square, ScrollText } from 'lucide-react'
import {
Plus, X, Box, ShieldAlert, RotateCw, Play, Square, ScrollText,
Terminal, Server, HelpCircle, Loader2, ArrowUpRight
} from 'lucide-react'
type DockerHost = { id: string; name: string; url: string }
type Container = {
@@ -60,7 +63,6 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
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)
}
@@ -71,182 +73,259 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
}
const deleteHost = async (id: string) => {
if (!confirm('Docker bağlantısını sil?')) return
if (!confirm('Docker bağlantısını silmek istediğinize emin misiniz?')) 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
<div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* Dockerus Top Header Bar */}
<header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 text-accent bg-accent/10 border border-accent/20 rounded-lg flex items-center justify-center p-1.5 shadow-[0_0_12px_rgba(0,229,255,0.1)]">
<Box className="w-full h-full text-accent" />
</div>
<span className="font-extrabold text-base tracking-tight text-white select-none">Dockerus</span>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setShowAdd(true)}
className="h-8 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0] px-3 font-semibold text-xs gap-1.5"
>
<Plus className="w-3.5 h-3.5" /> Docker Host Ekle
</Button>
<span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
Community
</span>
</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>
</header>
{/* 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>
{/* Main Two-Column Layout */}
<div className="flex flex-1 overflow-hidden">
{/* Left Sidepane: Docker Host selector list */}
<aside className="w-80 bg-[#0b0f19] border-r border-border flex flex-col shrink-0">
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{hosts.length === 0 ? (
<div className="py-8 text-center text-muted font-mono text-xs">
No Docker hosts configured
</div>
) : (
hosts.map(host => {
const isSelected = selected?.id === host.id
return (
<div
key={host.id}
onClick={() => select(host)}
className={`p-4 rounded-xl cursor-pointer transition-all duration-200 border relative group ${
isSelected
? 'bg-[#141b2a] border-accent/60 shadow-[0_0_15px_rgba(0,229,255,0.04)] ring-1 ring-accent/20'
: 'bg-[#131926] border-[#1d2639] hover:border-accent/40'
}`}
>
<div className="flex items-center gap-2 mb-2 pr-8">
<Terminal className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="text-sm font-bold text-white truncate tracking-tight">{host.name}</span>
</div>
<div className="text-[11px] text-muted/70 font-mono truncate">
{host.url}
</div>
{/* Floating delete button */}
<button
onClick={e => { e.stopPropagation(); deleteHost(host.id) }}
className="absolute top-3.5 right-3.5 opacity-0 group-hover:opacity-100 transition-opacity p-1 bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white rounded border border-red-500/20"
title="Sil"
>
<X className="w-3 h-3" />
</button>
</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 className="p-4 border-t border-border bg-[#0a0d16] shrink-0">
<Button
onClick={() => setShowAdd(true)}
className="w-full bg-[#1a56db] hover:bg-[#1a56db]/90 text-white font-semibold py-2.5 rounded-xl border border-transparent transition-all duration-200 flex items-center justify-center gap-1.5 shadow-md text-xs uppercase tracking-wider"
>
<Plus className="w-4 h-4" /> Add Docker Host
</Button>
</div>
</aside>
{/* Right Sidepane: Container metrics and dashboard */}
<main className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#070a13] relative">
{!selected ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted font-mono text-sm p-4 text-center">
<Box className="w-10 h-10 mb-4 text-muted/40 animate-pulse" />
<div className="max-w-xs text-xs font-semibold leading-relaxed">
Select a Docker host connection from the sidebar to query containers status logs and control action runtimes.
</div>
</div>
) : (
<div className="max-w-5xl mx-auto fade-up space-y-6">
{/* Host heading info */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-border/40 pb-5">
<div>
<h2 className="text-2xl font-extrabold text-white tracking-tight mb-1">{selected.name}</h2>
<div className="text-xs text-muted/80 font-mono bg-[#0b0f19] border border-border/80 w-fit px-3 py-1 rounded-md">
Docker Engine API: <span className="text-accent">{selected.url}</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => select(selected)} disabled={loading}>
<RotateCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
<Button
variant="outline"
size="sm"
onClick={() => select(selected)}
disabled={loading}
className="h-8 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0] font-semibold text-xs px-4"
>
<RotateCw className={`w-3.5 h-3.5 mr-2 ${loading ? 'animate-spin' : ''}`} />
Yenile
</Button>
</div>
{/* Error messages */}
{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 className="p-4 text-xs font-mono bg-red-500/5 text-[#ff3d71] flex items-center gap-2.5 border border-red-500/20 rounded-xl">
<ShieldAlert className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Containers cards table list */}
{!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', 'İş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>
))}
</tr>
</thead>
<tbody>
{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' : ''}`}>
{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>
<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>
)}
<Card className="bg-[#0b0f19] border-border/80 shadow-xl overflow-hidden">
{loading && containers.length === 0 ? (
<div className="p-8 text-center text-muted font-mono text-xs animate-pulse flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span>Konteynerler sorgulanıyor...</span>
</div>
) : containers.length === 0 ? (
<div className="p-8 text-center text-muted font-mono text-xs">
Aktif docker konteyneri bulunamadı.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-[#0e1422]">
{['Konteyner İsim', 'İmaj', 'Durum', 'Port Eşleşmeleri', 'İşlemler'].map(h => (
<th key={h} className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-muted/80 border-b border-border/80">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{containers.map((c, idx) => {
const name = c.Names?.[0]?.replace(/^\//, '') || 'Unknown'
const isRunning = c.State === 'running'
const isActing = actionLoading?.startsWith(c.Id)
return (
<tr key={c.Id} className="border-b border-border/40 hover:bg-white/[0.01] transition-colors duration-150">
<td className="px-6 py-4 text-sm font-bold text-accent tracking-tight">
{name}
</td>
<td className="px-6 py-4 text-xs font-mono text-muted/85 max-w-[200px] truncate" title={c.Image}>
{c.Image}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2.5">
<span className="relative flex h-2 w-2 shrink-0">
{isRunning && (
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#00e676] opacity-75"></span>
)}
<span className={`relative inline-flex rounded-full h-2 w-2 ${isRunning ? 'bg-[#00e676]' : 'bg-muted'}`}></span>
</span>
<span className={`text-xs font-bold font-mono ${isRunning ? 'text-[#00e676]' : 'text-muted'}`}>
{c.Status}
</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{c.Ports?.map((p, pIdx) => (
<span key={pIdx} className="bg-black/35 text-[10px] font-mono text-muted/80 px-2 py-0.5 rounded border border-border/60 select-none">
{p.PublicPort ? `${p.PublicPort}${p.PrivatePort}` : p.PrivatePort}/{p.Type}
</span>
))}
{(!c.Ports || c.Ports.length === 0) && (
<span className="text-xs text-muted/50 font-mono"></span>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5">
{isRunning ? (
<>
<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"
onClick={() => containerAction(c.Id, 'stop')}
disabled={!!isActing}
title="Durdur"
className="p-1.5 rounded-lg text-muted hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/15 transition-all disabled:opacity-40"
>
<ScrollText className="w-3.5 h-3.5" />
<Square className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
}
<button
onClick={() => containerAction(c.Id, 'restart')}
disabled={!!isActing}
title="Yeniden Başlat"
className="p-1.5 rounded-lg text-muted hover:text-[#ffab00] hover:bg-[#ffab00]/10 border border-transparent hover:border-[#ffab00]/15 transition-all 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-[#00e676] hover:bg-[#00e676]/10 border border-transparent hover:border-[#00e676]/15 transition-all 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 border border-transparent hover:border-accent/15 transition-all"
>
<ScrollText className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</Card>
)}
</div>
)}
</main>
</div>
{showAdd && <AddHostModal onClose={() => setShowAdd(false)} onAdded={() => { loadHosts(); setShowAdd(false) }} />}
{/* Add Host Modal overlay */}
{showAdd && (
<AddHostModal
onClose={() => setShowAdd(false)}
onAdded={() => { loadHosts(); setShowAdd(false) }}
/>
)}
{/* Logs Modal overlay */}
{logsModal && selected && (
<LogsModal
hostId={selected.id}
@@ -283,32 +362,27 @@ function LogsModal({ hostId, containerId, name, onClose }: {
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">
<div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-4xl max-h-[85vh] flex flex-col shadow-2xl bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border/80">
<div className="flex items-center gap-2.5">
<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>
<span className="font-bold text-sm text-white tracking-tight">{name}</span>
<span className="text-[10px] text-muted/65 font-mono bg-black/20 border border-border/60 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 variant="ghost" size="sm" onClick={load} disabled={loading} className="h-7 text-xs border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]">
<RotateCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} /> Yenile
</Button>
<button onClick={onClose} className="text-muted hover:text-text transition-colors">
<button onClick={onClose} className="p-1 text-muted hover:text-white rounded 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 className="flex-1 overflow-auto p-5 bg-black/40 rounded-b-2xl custom-scrollbar font-mono text-[11px] text-[#e2e8f0]/85 whitespace-pre-wrap leading-relaxed shadow-inner">
{logs}
</div>
</div>
</Card>
</div>
)
}
@@ -330,27 +404,35 @@ function AddHostModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =
}
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>
<div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[420px] shadow-2xl bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/80">
<CardTitle className="text-white text-lg font-bold">Yeni Docker Host</CardTitle>
<CardDescription className="text-muted/80">Sorgulamak istediğiniz uzak Docker API adresini 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">Sunucu Adı</label>
<Input placeholder="Remote VPS" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} />
<Input placeholder="Remote VPS" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
</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>
<Input placeholder="http://192.168.1.10:2375" value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} className="bg-black/20 border-[#1d2639] font-mono" />
<p className="text-[9px] text-muted/65 font-mono leading-normal mt-1.5">
Uzak sunucudaki Docker daemon'ının TCP portundan dışarıya güvenli bir şekilde açık 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-xs font-mono bg-red-500/5 text-[#ff3d71] p-3 rounded-lg border border-red-500/20 mt-3 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 shrink-0" />
<span>{error}</span>
</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">
<div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]">İptal</Button>
<Button onClick={submit} disabled={loading || !f.name || !f.url} className="flex-1 bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold">
{loading ? 'Ekleniyor...' : 'Ekle'}
</Button>
</div>
+272 -241
View File
@@ -1,8 +1,9 @@
'use client'
import React, { useState } from 'react'
import { Search, Monitor, Smartphone, AlertCircle, Gauge, Activity, Zap, CheckCircle2, XCircle, AlertTriangle, Info } from 'lucide-react'
import { Search, Monitor, Smartphone, AlertCircle, Gauge, Activity, Zap, CheckCircle2, XCircle, AlertTriangle, Info, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
interface AuditItem {
id: string
@@ -43,7 +44,6 @@ export default function InsightPage() {
const result = await res.json()
if (!res.ok) throw new Error(result.error || 'Analiz başarısız')
setData(result)
} catch (err: any) {
setError(err.message)
@@ -53,21 +53,21 @@ export default function InsightPage() {
}
const getScoreColor = (score: number) => {
if (score >= 0.9) return 'text-green-500 border-green-500 bg-green-500/10'
if (score >= 0.5) return 'text-orange-500 border-orange-500 bg-orange-500/10'
return 'text-red-500 border-red-500 bg-red-500/10'
if (score >= 0.9) return 'text-[#00e676] border-[#00e676] bg-[#00e676]/10 shadow-[0_0_12px_rgba(0,230,118,0.1)]'
if (score >= 0.5) return 'text-[#ffab00] border-[#ffab00] bg-[#ffab00]/10 shadow-[0_0_12px_rgba(255,171,0,0.1)]'
return 'text-[#ff3d71] border-[#ff3d71] bg-[#ff3d71]/10 shadow-[0_0_12px_rgba(255,61,113,0.1)]'
}
const getScoreTextColor = (score: number) => {
if (score >= 0.9) return 'text-green-500'
if (score >= 0.5) return 'text-orange-500'
return 'text-red-500'
if (score >= 0.9) return 'text-[#00e676]'
if (score >= 0.5) return 'text-[#ffab00]'
return 'text-[#ff3d71]'
}
const getScoreBgColor = (score: number) => {
if (score >= 0.9) return 'bg-green-500'
if (score >= 0.5) return 'bg-orange-500'
return 'bg-red-500'
if (score >= 0.9) return 'bg-[#00e676]'
if (score >= 0.5) return 'bg-[#ffab00]'
return 'bg-[#ff3d71]'
}
const formatMetric = (value: number, unit: string = 's') => {
@@ -80,257 +80,288 @@ export default function InsightPage() {
const diagnostics: AuditItem[] = data?.lighthouseResult?.diagnostics ?? []
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text flex items-center gap-2">
<Gauge className="w-6 h-6 text-accent" />
Page Insights
</h1>
<p className="text-muted mt-1">Lighthouse motoru ile web sayfalarınızın performansını, erişilebilirliğini ve SEO'sunu ölçün.</p>
</div>
<div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* Insightus Top Header Bar */}
<header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 text-accent bg-accent/10 border border-accent/20 rounded-lg flex items-center justify-center p-1.5 shadow-[0_0_12px_rgba(0,229,255,0.1)]">
<Gauge className="w-full h-full text-accent" />
</div>
<span className="font-extrabold text-base tracking-tight text-white select-none">Insightus</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
Community
</span>
</div>
</header>
<div className="bg-surface border border-border rounded-xl p-4 sm:p-6 mb-8">
<form onSubmit={handleAnalyze} className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-muted" />
</div>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://ornek.com"
className="block w-full pl-10 pr-3 py-3 border border-border bg-background rounded-lg text-text placeholder-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
required
/>
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#070a13]">
<div className="max-w-5xl mx-auto space-y-6 fade-up">
{/* Subtitle description */}
<div>
<p className="text-xs text-muted/80 font-mono bg-[#0b0f19] border border-border/80 w-fit px-3 py-1 rounded-md">
Lighthouse motoru ile web sayfalarınızın performansını, erişilebilirliğini ve SEO'sunu ölçün.
</p>
</div>
<div className="flex bg-background border border-border rounded-lg p-1">
<button
type="button"
onClick={() => setStrategy('mobile')}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
strategy === 'mobile' ? "bg-surface text-text shadow-sm" : "text-muted hover:text-text"
)}
>
<Smartphone className="w-4 h-4" />
Mobil
</button>
<button
type="button"
onClick={() => setStrategy('desktop')}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
strategy === 'desktop' ? "bg-surface text-text shadow-sm" : "text-muted hover:text-text"
)}
>
<Monitor className="w-4 h-4" />
Masaüstü
</button>
</div>
<button
type="submit"
disabled={loading || !url}
className="bg-accent hover:bg-accent/90 text-white px-8 py-3 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 min-w-[140px]"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Analiz...
</>
) : (
'Analiz Et'
)}
</button>
</form>
{error && (
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex items-start gap-3 text-destructive">
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
<p className="text-sm">{error}</p>
</div>
)}
</div>
{data && data.lighthouseResult && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col items-center mb-8">
<p className="text-sm text-muted mb-2">Test edilen URL</p>
<a href={data.id} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline break-all text-center">
{data.id}
</a>
</div>
{/* Kategori skorları */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{[
{ id: 'performance', label: 'Performans' },
{ id: 'accessibility', label: 'Erişilebilirlik' },
{ id: 'best-practices', label: 'En İyi Pratikler' },
{ id: 'seo', label: 'SEO' },
].map((cat) => {
const score = data.lighthouseResult.categories[cat.id]?.score ?? 0
return (
<div key={cat.id} className="bg-surface border border-border rounded-xl p-6 flex flex-col items-center justify-center text-center">
<div className={cn(
"w-24 h-24 rounded-full border-4 flex items-center justify-center mb-4 transition-all duration-1000",
getScoreColor(score)
)}>
<span className="text-3xl font-bold">{Math.round(score * 100)}</span>
{/* Search URL form container */}
<Card className="bg-[#0b0f19] border-border/80 shadow-md">
<CardContent className="pt-6">
<form onSubmit={handleAnalyze} className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-muted/70" />
</div>
<h3 className="font-medium text-text">{cat.label}</h3>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://ornek.com"
className="block w-full pl-10 pr-3.5 py-2.5 border border-[#1d2639] bg-black/20 rounded-xl text-sm text-[#e2e8f0] placeholder-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-all font-mono"
required
/>
</div>
)
})}
</div>
{/* Core Web Vitals */}
<div className="bg-surface border border-border rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2">
<Activity className="w-5 h-5 text-accent" />
<h2 className="font-semibold text-text">Core Web Vitals & Metrikler</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="flex bg-black/20 border border-[#1d2639] rounded-xl p-1 shrink-0">
<button
type="button"
onClick={() => setStrategy('mobile')}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-xs font-bold transition-all",
strategy === 'mobile' ? "bg-[#141b2a] text-white border border-accent/20 shadow-sm" : "text-muted hover:text-white"
)}
>
<Smartphone className="w-3.5 h-3.5" />
Mobil
</button>
<button
type="button"
onClick={() => setStrategy('desktop')}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-xs font-bold transition-all",
strategy === 'desktop' ? "bg-[#141b2a] text-white border border-accent/20 shadow-sm" : "text-muted hover:text-white"
)}
>
<Monitor className="w-3.5 h-3.5" />
Masaüstü
</button>
</div>
<Button
type="submit"
disabled={loading || !url}
className="bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold text-xs uppercase tracking-wider py-2.5 px-8 rounded-xl shrink-0 gap-1.5 shadow"
>
{loading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Analiz...
</>
) : (
'Analiz Et'
)}
</Button>
</form>
{error && (
<div className="mt-4 p-4 bg-red-500/5 border border-red-500/20 rounded-xl flex items-start gap-3 text-[#ff3d71] font-mono text-xs">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<p>{error}</p>
</div>
)}
</CardContent>
</Card>
{/* Results Analysis output container */}
{data && data.lighthouseResult && (
<div className="space-y-8 fade-up">
{/* Tested destination details */}
<div className="flex flex-col items-center justify-center p-6 bg-[#0b0f19] border border-border/80 rounded-xl shadow-md text-center">
<span className="text-[10px] text-muted font-mono uppercase tracking-wider mb-1.5">Test Edilen Adres</span>
<a href={data.id} target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-accent hover:underline break-all font-mono">
{data.id}
</a>
</div>
{/* Lighthouse Category score circles */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-5">
{[
{ id: 'first-contentful-paint', label: 'First Contentful Paint', desc: 'İlk metin veya görselin görünme süresi', unit: 's' },
{ id: 'largest-contentful-paint', label: 'Largest Contentful Paint', desc: 'En büyük içeriğin görünme süresi', unit: 's' },
{ id: 'total-blocking-time', label: 'Total Blocking Time', desc: 'Etkileşimi engelleyen toplam süre', unit: 'ms' },
{ id: 'cumulative-layout-shift', label: 'Cumulative Layout Shift', desc: 'Beklenmeyen düzen kayması miktarı', unit: 'unitless' },
{ id: 'speed-index', label: 'Speed Index', desc: 'İçeriklerin görsel olarak dolma hızı', unit: 's' },
{ id: 'interactive', label: 'Time to Interactive', desc: 'Sayfanın tamamen etkileşimli olma süresi', unit: 's' },
].map((metric) => {
const audit = data.lighthouseResult.audits[metric.id]
if (!audit) return null
{ id: 'performance', label: 'Performans' },
{ id: 'accessibility', label: 'Erişilebilirlik' },
{ id: 'best-practices', label: 'En İyi Pratikler' },
{ id: 'seo', label: 'SEO' },
].map((cat) => {
const score = data.lighthouseResult.categories[cat.id]?.score ?? 0
return (
<div key={metric.id} className="p-4 rounded-lg bg-background border border-border">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-medium text-text">{metric.label}</h4>
<p className="text-xs text-muted mt-1">{metric.desc}</p>
</div>
<div className={cn("text-lg font-bold shrink-0 ml-2", getScoreTextColor(audit.score))}>
{formatMetric(audit.numericValue, metric.unit)}
</div>
<Card key={cat.id} className="bg-[#0b0f19] border-border/80 shadow flex flex-col items-center justify-center text-center p-6">
<div className={cn(
"w-20 h-20 rounded-full border-4 flex items-center justify-center mb-4 transition-all duration-1000 font-extrabold text-2xl select-none",
getScoreColor(score)
)}>
{Math.round(score * 100)}
</div>
<div className="w-full bg-surface-2 rounded-full h-1.5 mt-3">
<div
className={cn("h-1.5 rounded-full", getScoreBgColor(audit.score))}
style={{ width: `${Math.max(5, audit.score * 100)}%` }}
/>
</div>
</div>
<h3 className="text-xs font-bold text-white tracking-tight">{cat.label}</h3>
</Card>
)
})}
</div>
</div>
</div>
{/* Fırsatlar */}
{opportunities.length > 0 && (
<div className="bg-surface border border-border rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2">
<Zap className="w-5 h-5 text-orange-500" />
<h2 className="font-semibold text-text">Fırsatlar</h2>
<span className="text-xs text-muted ml-auto">Sayfa yüklenme süresini kısaltabilecek öneriler</span>
</div>
<div className="divide-y divide-border">
{opportunities.map((audit) => (
<div key={audit.id} className="px-6 py-4 flex items-start gap-4">
<div className="mt-0.5 shrink-0">
{audit.score !== null && audit.score < 0.5
? <XCircle className="w-4 h-4 text-red-500" />
: <AlertTriangle className="w-4 h-4 text-orange-500" />
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text">{audit.title}</p>
{audit.description && (
<p className="text-xs text-muted mt-0.5 line-clamp-1">
{stripMarkdown(audit.description)}
</p>
)}
</div>
{audit.displayValue && (
<span className={cn(
"text-sm font-mono shrink-0",
audit.score !== null && audit.score < 0.5 ? 'text-red-500' : 'text-orange-500'
)}>
{audit.displayValue}
</span>
)}
{/* Web Vitals speed audit metrics cards */}
<Card className="bg-[#0b0f19] border-border/80 shadow">
<CardHeader className="pb-3 border-b border-border/30 flex flex-row items-center gap-2">
<Activity className="w-4 h-4 text-accent" />
<CardTitle className="text-xs text-white font-bold tracking-tight">Core Web Vitals & Hız Metrikleri</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{ id: 'first-contentful-paint', label: 'First Contentful Paint', desc: 'İlk metin veya görselin görünme süresi', unit: 's' },
{ id: 'largest-contentful-paint', label: 'Largest Contentful Paint', desc: 'En büyük içeriğin görünme süresi', unit: 's' },
{ id: 'total-blocking-time', label: 'Total Blocking Time', desc: 'Etkileşimi engelleyen toplam süre', unit: 'ms' },
{ id: 'cumulative-layout-shift', label: 'Cumulative Layout Shift', desc: 'Beklenmeyen düzen kayması miktarı', unit: 'unitless' },
{ id: 'speed-index', label: 'Speed Index', desc: 'İçeriklerin görsel olarak dolma hızı', unit: 's' },
{ id: 'interactive', label: 'Time to Interactive', desc: 'Sayfanın tamamen etkileşimli olma süresi', unit: 's' },
].map((metric) => {
const audit = data.lighthouseResult.audits[metric.id]
if (!audit) return null
return (
<div key={metric.id} className="p-4 rounded-xl bg-black/15 border border-[#1d2639] flex flex-col justify-between">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="text-xs font-bold text-white tracking-tight">{metric.label}</h4>
<p className="text-[10px] text-muted mt-1 leading-normal">{metric.desc}</p>
</div>
<div className={cn("text-sm font-bold shrink-0 ml-2 font-mono", getScoreTextColor(audit.score))}>
{formatMetric(audit.numericValue, metric.unit)}
</div>
</div>
<div className="w-full bg-black/35 rounded-full h-1 mt-3">
<div
className={cn("h-1 rounded-full", getScoreBgColor(audit.score))}
style={{ width: `${Math.max(5, audit.score * 100)}%` }}
/>
</div>
</div>
)
})}
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Teşhisler */}
{diagnostics.length > 0 && (
<div className="bg-surface border border-border rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2">
<Info className="w-5 h-5 text-accent" />
<h2 className="font-semibold text-text">Teşhisler</h2>
<span className="text-xs text-muted ml-auto">Performansı etkileyen ek bilgiler</span>
</div>
<div className="divide-y divide-border">
{diagnostics.map((audit) => {
const passed = audit.score === null || audit.score >= 0.9
return (
<div key={audit.id} className="px-6 py-4 flex items-start gap-4">
<div className="mt-0.5 shrink-0">
{audit.score === null
? <Info className="w-4 h-4 text-muted" />
: passed
? <CheckCircle2 className="w-4 h-4 text-green-500" />
: audit.score < 0.5
? <XCircle className="w-4 h-4 text-red-500" />
: <AlertTriangle className="w-4 h-4 text-orange-500" />
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text">{audit.title}</p>
{audit.description && (
<p className="text-xs text-muted mt-0.5 line-clamp-1">
{stripMarkdown(audit.description)}
</p>
{/* Opportunities warnings */}
{opportunities.length > 0 && (
<Card className="bg-[#0b0f19] border-border/80 shadow overflow-hidden">
<CardHeader className="pb-3 border-b border-border/30 bg-black/5 flex flex-row items-center gap-2">
<Zap className="w-4 h-4 text-[#ffab00]" />
<CardTitle className="text-xs text-white font-bold tracking-tight">Fırsatlar</CardTitle>
<span className="text-[10px] text-muted ml-auto font-mono">Sayfa yüklenme süresini kısaltabilecek öneriler</span>
</CardHeader>
<div className="divide-y divide-border/40 bg-black/5">
{opportunities.map((audit) => (
<div key={audit.id} className="px-6 py-4 flex items-start gap-4 hover:bg-white/[0.005] transition-all">
<div className="mt-0.5 shrink-0">
{audit.score !== null && audit.score < 0.5 ? (
<XCircle className="w-4 h-4 text-[#ff3d71]" />
) : (
<AlertTriangle className="w-4 h-4 text-[#ffab00]" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-white tracking-tight">{audit.title}</p>
{audit.description && (
<p className="text-[11px] text-muted mt-1 leading-normal">
{stripMarkdown(audit.description)}
</p>
)}
</div>
{audit.displayValue && (
<span className={cn(
"text-xs font-mono font-bold shrink-0",
audit.score !== null && audit.score < 0.5 ? 'text-[#ff3d71]' : 'text-[#ffab00]'
)}>
{audit.displayValue}
</span>
)}
</div>
{audit.displayValue && (
<span className={cn(
"text-sm font-mono shrink-0",
audit.score === null ? 'text-muted' : passed ? 'text-green-500' : audit.score < 0.5 ? 'text-red-500' : 'text-orange-500'
)}>
{audit.displayValue}
</span>
)}
</div>
)
})}
))}
</div>
</Card>
)}
{/* Diagnostics notes */}
{diagnostics.length > 0 && (
<Card className="bg-[#0b0f19] border-border/80 shadow overflow-hidden">
<CardHeader className="pb-3 border-b border-border/30 bg-black/5 flex flex-row items-center gap-2">
<Info className="w-4 h-4 text-accent" />
<CardTitle className="text-xs text-white font-bold tracking-tight">Teşhisler</CardTitle>
<span className="text-[10px] text-muted ml-auto font-mono">Performansı etkileyen ek sistem bilgileri</span>
</CardHeader>
<div className="divide-y divide-border/40 bg-black/5">
{diagnostics.map((audit) => {
const passed = audit.score === null || audit.score >= 0.9
return (
<div key={audit.id} className="px-6 py-4 flex items-start gap-4 hover:bg-white/[0.005] transition-all">
<div className="mt-0.5 shrink-0">
{audit.score === null ? (
<Info className="w-4 h-4 text-muted/70" />
) : passed ? (
<CheckCircle2 className="w-4 h-4 text-[#00e676]" />
) : audit.score < 0.5 ? (
<XCircle className="w-4 h-4 text-[#ff3d71]" />
) : (
<AlertTriangle className="w-4 h-4 text-[#ffab00]" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-white tracking-tight">{audit.title}</p>
{audit.description && (
<p className="text-[11px] text-muted mt-1 leading-normal">
{stripMarkdown(audit.description)}
</p>
)}
</div>
{audit.displayValue && (
<span className={cn(
"text-xs font-mono font-bold shrink-0",
audit.score === null ? 'text-muted/80' : passed ? 'text-[#00e676]' : audit.score < 0.5 ? 'text-[#ff3d71]' : 'text-[#ffab00]'
)}>
{audit.displayValue}
</span>
)}
</div>
)
})}
</div>
</Card>
)}
{/* Legends explanation */}
<div className="flex flex-wrap items-center justify-center gap-6 text-xs text-muted/80 pt-4 font-mono select-none">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-[#ff3d71]" />
<span>0-49 Zayıf</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-[#ffab00]" />
<span>50-89 Ortalama</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-[#00e676]" />
<span>90-100 İyi</span>
</div>
</div>
</div>
)}
{/* Legend */}
<div className="flex items-center justify-center gap-6 text-sm text-muted pt-4">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-red-500" />
<span>0-49 Zayıf</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-orange-500" />
<span>50-89 Ortalama</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-green-500" />
<span>90-100 İyi</span>
</div>
</div>
</div>
)}
</div>
</div>
)
}
+182 -77
View File
@@ -1,5 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { LayoutDashboard, Globe, Database, Server, Clock, ArrowRight, Activity, Loader2 } from 'lucide-react'
type Config = {
sites: { id: string; name: string; url: string }[]
@@ -12,96 +13,200 @@ type SiteStatus = { id: string; status: string | null; ms: number | null; uptime
export default function OverviewPage() {
const [config, setConfig] = useState<Config | null>(null)
const [statuses, setStatuses] = useState<Record<string, SiteStatus>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/config').then(r => r.json()).then(async (cfg: Config) => {
setConfig(cfg)
// Her site için son durumu çek
const results = await Promise.all(
cfg.sites.map(s =>
fetch(`/api/ping?siteId=${s.id}`).then(r => r.json()).then(d => ({
id: s.id, status: d.last?.status ?? null, ms: d.last?.ms ?? null, uptime24: d.uptime24
}))
setLoading(true)
fetch('/api/config')
.then(r => r.json())
.then(async (cfg: Config) => {
setConfig(cfg)
// Fetch last statuses for each site
const results = await Promise.all(
(cfg.sites || []).map(s =>
fetch(`/api/ping?siteId=${s.id}`)
.then(r => r.json())
.then(d => ({
id: s.id,
status: d.last?.status ?? null,
ms: d.last?.ms ?? null,
uptime24: d.uptime24
}))
.catch(() => ({ id: s.id, status: 'down', ms: null, uptime24: 0 }))
)
)
)
const map: Record<string, SiteStatus> = {}
results.forEach(r => { map[r.id] = r })
setStatuses(map)
})
const map: Record<string, SiteStatus> = {}
results.forEach(r => { map[r.id] = r })
setStatuses(map)
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
if (!config) return <div style={{ padding: 28, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}>Yükleniyor...</div>
if (!config) {
return (
<div className="flex flex-col h-screen bg-[#070a13] text-[#e2e8f0]">
<header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center px-6">
<div className="flex items-center gap-2">
<LayoutDashboard className="w-5 h-5 text-accent" />
<span className="font-extrabold text-base text-white tracking-tight">Overview</span>
</div>
</header>
<div className="flex-1 flex items-center justify-center font-mono text-xs text-muted">
<Loader2 className="w-5 h-5 animate-spin mr-2 text-accent" />
Yükleniyor...
</div>
</div>
)
}
const statusColor = (s: string | null) => s === 'up' ? 'var(--green)' : s === 'down' ? 'var(--red)' : s === 'degraded' ? 'var(--yellow)' : 'var(--muted)'
const statusColor = (s: string | null) =>
s === 'up' ? 'text-[#00e676]' : s === 'down' ? 'text-[#ff3d71]' : s === 'degraded' ? 'text-[#ffab00]' : 'text-muted'
const statusBg = (s: string | null) =>
s === 'up' ? 'bg-[#00e676]' : s === 'down' ? 'bg-[#ff3d71]' : s === 'degraded' ? 'bg-[#ffab00]' : 'bg-muted'
return (
<div style={{ padding: 28 }}>
<div style={{ marginBottom: 28 }}>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Overview</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>
{config.sites.length} site · {config.databases.length} DB · {config.services.length} servis
</p>
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14, marginBottom: 28 }}>
{[
{ label: 'Siteler', value: `${config.sites.filter(s => statuses[s.id]?.status === 'up').length}/${config.sites.length}`, sub: 'online', color: 'var(--green)' },
{ label: 'Databases', value: String(config.databases.length), sub: 'kayıtlı', color: 'var(--accent)' },
{ label: 'Servisler', value: String(config.services.length), sub: 'tanımlı', color: 'var(--purple)' },
].map(s => (
<div key={s.label} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '18px 20px' }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>{s.label}</div>
<div style={{ fontSize: 28, fontWeight: 800, color: s.color, letterSpacing: -1 }}>{s.value}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>{s.sub}</div>
<div className="flex flex-col min-h-screen bg-[#070a13] text-[#e2e8f0]">
{/* Top Header Bar */}
<header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 text-accent bg-accent/10 border border-accent/20 rounded-lg flex items-center justify-center p-1.5 shadow-[0_0_12px_rgba(0,229,255,0.1)]">
<LayoutDashboard className="w-full h-full text-accent" />
</div>
))}
</div>
<span className="font-extrabold text-base tracking-tight text-white select-none">Overview</span>
</div>
<div>
<span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
Community
</span>
</div>
</header>
{/* Site statuses */}
{config.sites.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10 }}>Site Durumu</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{config.sites.map((site, i) => {
const st = statuses[site.id]
return (
<div key={site.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 18px', borderBottom: i < config.sites.length - 1 ? '1px solid var(--border)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor(st?.status ?? null), boxShadow: `0 0 6px ${statusColor(st?.status ?? null)}`, animation: st?.status === 'up' ? 'pulse 3s infinite' : 'none' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{site.name}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{site.url}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{st?.ms && <span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)' }}>{st.ms}ms</span>}
{st?.uptime24 !== undefined && <span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--green)' }}>{st.uptime24}%</span>}
<span style={{ fontSize: 11, fontFamily: 'monospace', fontWeight: 700, color: statusColor(st?.status ?? null) }}>
{st?.status?.toUpperCase() ?? '—'}
</span>
</div>
{/* Overview Content */}
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar max-w-5xl w-full mx-auto space-y-8 fade-up">
{/* Description Section */}
<div>
<p className="text-xs text-muted/80 font-mono bg-[#0b0f19] border border-border/80 w-fit px-3 py-1 rounded-md">
{config.sites.length} site · {config.databases.length} DB · {config.services.length} servis
</p>
</div>
{/* Statistical Metrics cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{
label: 'Sites Online',
value: `${config.sites.filter(s => statuses[s.id]?.status === 'up').length}/${config.sites.length}`,
sub: 'online',
color: 'text-[#00e676]',
icon: Globe,
glowColor: 'rgba(0,230,118,0.1)'
},
{
label: 'Databases',
value: String(config.databases.length),
sub: 'registered connections',
color: 'text-accent',
icon: Database,
glowColor: 'rgba(0,229,255,0.1)'
},
{
label: 'Services',
value: String(config.services.length),
sub: 'configured portals',
color: 'text-purple-400',
icon: Server,
glowColor: 'rgba(123,97,255,0.1)'
},
].map(s => (
<div
key={s.label}
className="bg-[#0b0f19] border border-[#1d2639] hover:border-accent/40 rounded-xl p-6 transition-all duration-200 group"
style={{ boxShadow: `0 4px 20px -5px rgba(0,0,0,0.3)` }}
>
<div className="flex justify-between items-start mb-4">
<span className="text-[10px] text-muted font-mono uppercase tracking-widest">{s.label}</span>
<s.icon className="w-4 h-4 text-muted/40 group-hover:text-accent transition-colors" />
</div>
<div className={`text-3xl font-extrabold ${s.color} tracking-tight mb-1`}>{s.value}</div>
<div className="text-[11px] text-muted/60 font-mono">{s.sub}</div>
</div>
))}
</div>
{/* Site Status List */}
{config.sites.length > 0 && (
<div className="space-y-4">
<h3 className="text-xs text-muted font-mono uppercase tracking-widest">Site Status</h3>
<div className="bg-[#0b0f19] border border-border/80 rounded-xl overflow-hidden shadow-xl">
{loading ? (
<div className="p-8 text-center text-muted font-mono text-xs animate-pulse">
Checking site statuses...
</div>
)
})}
) : (
<div className="divide-y divide-border/40">
{config.sites.map((site) => {
const st = statuses[site.id]
const isUp = st?.status === 'up'
return (
<div key={site.id} className="flex items-center justify-between p-4 px-6 hover:bg-white/[0.01] transition-all duration-150">
<div className="flex items-center gap-3.5">
<span className="relative flex h-2.5 w-2.5 shrink-0">
{isUp && (
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${statusBg(st?.status ?? null)}`}></span>
)}
<span className={`relative inline-flex rounded-full h-2.5 w-2.5 ${statusBg(st?.status ?? null)}`}></span>
</span>
<div>
<div className="text-sm font-bold text-white tracking-tight">{site.name}</div>
<div className="text-[11px] text-muted/60 font-mono mt-0.5">{site.url}</div>
</div>
</div>
<div className="flex items-center gap-8 text-xs font-mono font-bold">
{st?.ms !== null && st?.ms !== undefined && (
<span className="text-muted/70">{st.ms}ms</span>
)}
{st?.uptime24 !== undefined && (
<span className="text-[#00e676]">{st.uptime24}%</span>
)}
<span className={`${statusColor(st?.status ?? null)}`}>
{st?.status?.toUpperCase() ?? '—'}
</span>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)}
{/* Quick Nav Grid */}
<div className="space-y-4">
<h3 className="text-xs text-muted font-mono uppercase tracking-widest">Quick Navigation</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{[
{ href: '/dashboard/uptime', label: 'Uptime Monitor', desc: 'Site ekle, ping at, log gör', color: 'border-emerald-500/20 text-[#00e676]' },
{ href: '/dashboard/databases', label: 'Database Console', desc: 'Tabloları incele, SQL çalıştır, yedek al', color: 'border-accent/20 text-accent' },
{ href: '/dashboard/services', label: 'External Portals', desc: 'Coolify, Grafana ve n8n araçlarına git', color: 'border-purple-500/20 text-purple-400' },
].map(item => (
<a
key={item.href}
href={item.href}
className={`block bg-[#0b0f19] border border-[#1d2639] hover:border-accent/40 rounded-xl p-5 group transition-all duration-200`}
style={{ boxShadow: `0 4px 20px -5px rgba(0,0,0,0.3)` }}
>
<div className="flex items-center justify-between mb-2">
<div className={`text-sm font-bold tracking-tight transition-colors group-hover:text-accent`}>{item.label}</div>
<ArrowRight className="w-3.5 h-3.5 text-muted/40 group-hover:text-accent group-hover:translate-x-1 transition-all" />
</div>
<div className="text-xs text-muted/70 leading-normal">{item.desc}</div>
</a>
))}
</div>
</div>
)}
{/* Quick nav */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12 }}>
{[
{ href: '/dashboard/uptime', label: 'Uptime →', desc: 'Site ekle, ping at, log gör', color: 'var(--green)' },
{ href: '/dashboard/databases', label: 'Databases →', desc: 'Bağlan, tablo gör, query çalıştır', color: 'var(--accent)' },
{ href: '/dashboard/services', label: 'Servisler →', desc: 'Coolify, Grafana vs. bağlantıları', color: 'var(--purple)' },
].map(item => (
<a key={item.href} href={item.href} style={{ display: 'block', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px', textDecoration: 'none', color: 'var(--text)', transition: 'border-color .2s' }}
onMouseOver={e => (e.currentTarget.style.borderColor = item.color)}
onMouseOut={e => (e.currentTarget.style.borderColor = 'var(--border)')}>
<div style={{ fontSize: 14, fontWeight: 700, color: item.color, marginBottom: 4 }}>{item.label}</div>
<div style={{ fontSize: 12, color: 'var(--muted)' }}>{item.desc}</div>
</a>
))}
</div>
</div>
)
+163 -75
View File
@@ -1,5 +1,9 @@
'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 { Plus, X, Server, Edit2, Trash2, ExternalLink, ArrowUpRight, HelpCircle } from 'lucide-react'
type Service = { id: string; name: string; url: string; icon: string; description: string }
@@ -11,64 +15,128 @@ export default function ServicesPage() {
const load = useCallback(async () => {
const r = await fetch('/api/config')
const cfg = await r.json()
setServices(cfg.services)
setServices(cfg.services || [])
}, [])
useEffect(() => { load() }, [load])
const del = async (id: string) => {
if (!confirm('Sil?')) return
if (!confirm('Silmek istediğinize emin misiniz?')) return
await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'service', id }) })
load()
}
return (
<div style={{ padding: 28 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Servisler</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Coolify, Grafana, n8n vs. hızlı erişim linkleri</p>
<div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* Servicus Top Header Bar */}
<header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 text-accent bg-accent/10 border border-accent/20 rounded-lg flex items-center justify-center p-1.5 shadow-[0_0_12px_rgba(0,229,255,0.1)]">
<Server className="w-full h-full text-accent" />
</div>
<span className="font-extrabold text-base tracking-tight text-white select-none">Servicus</span>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setShowAdd(true)}
className="h-8 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0] px-3 font-semibold text-xs gap-1.5"
>
<Plus className="w-3.5 h-3.5" /> Servis Ekle
</Button>
<span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
Community
</span>
</div>
</header>
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#070a13]">
<div className="max-w-5xl mx-auto space-y-6 fade-up">
{/* Subtitle description */}
<div>
<p className="text-xs text-muted/80 font-mono bg-[#0b0f19] border border-border/80 w-fit px-3 py-1 rounded-md">
Coolify, Grafana, n8n vs. hızlı erişim linkleri
</p>
</div>
{/* Grids list */}
{services.length === 0 ? (
<Card className="border-dashed border-2 bg-[#0b0f19]/30 border-border/80 py-16">
<CardContent className="flex flex-col items-center justify-center text-center">
<Server className="w-12 h-12 text-muted/30 mb-4 animate-pulse" />
<h3 className="text-sm font-bold text-white mb-1.5 select-none">Henüz servis tanımlanmadı</h3>
<p className="text-xs text-muted/80 mb-5 max-w-sm">
Coolify, Grafana, n8n gibi harici portallarınızı ekleyerek panelinizden hızlıca erişebilirsiniz.
</p>
<Button onClick={() => setShowAdd(true)} className="bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold">
<Plus className="w-4 h-4 mr-1.5" /> Servis Ekle
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{services.map(svc => (
<div
key={svc.id}
className="bg-[#0b0f19] border border-[#1d2639] hover:border-accent/40 rounded-xl p-5 relative group transition-all duration-200"
style={{ boxShadow: `0 4px 20px -5px rgba(0,0,0,0.3)` }}
>
{/* Presets absolute controls on hover */}
<div className="absolute top-3.5 right-3.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
<button
onClick={() => setEditingService(svc)}
className="p-1 bg-[#1a1f2e] hover:bg-[#252c3e] text-muted hover:text-white rounded border border-[#1d2639]"
title="Düzenle"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => del(svc.id)}
className="p-1 bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white rounded border border-red-500/20"
title="Sil"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{/* Icon details */}
<div className="text-3xl mb-4 select-none">{svc.icon}</div>
<h4 className="text-sm font-bold text-white mb-1 truncate tracking-tight pr-10">{svc.name}</h4>
<p className="text-xs text-muted/80 leading-normal min-h-[32px] line-clamp-2 mb-4 font-mono">
{svc.description || 'Herhangi bir açıklama girilmedi.'}
</p>
{/* Action link */}
<a
href={svc.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 bg-accent/10 border border-accent/20 hover:border-accent/40 hover:bg-accent/15 text-accent text-[11px] font-bold font-mono px-3.5 py-1.5 rounded-lg transition-all"
>
<span></span>
<ArrowUpRight className="w-3 h-3" />
</a>
</div>
))}
</div>
)}
</div>
<button onClick={() => setShowAdd(true)} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: '9px 18px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>+ Servis Ekle</button>
</div>
{services.length === 0
? (
<div style={{ background: 'var(--surface)', border: '1px dashed var(--border)', borderRadius: 12, padding: '40px', textAlign: 'center', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}>
Henüz servis eklenmedi.<br />
<span style={{ color: 'var(--accent)', cursor: 'pointer' }} onClick={() => setShowAdd(true)}>+ Ekle</span> ile Coolify, Grafana gibi araçlara hızlı erişim ekle.
</div>
)
: (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
{services.map(svc => (
<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)')}>
<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>
<a href={svc.url} target="_blank" rel="noreferrer"
style={{ display: 'inline-block', background: 'rgba(0,229,255,.08)', border: '1px solid rgba(0,229,255,.2)', borderRadius: 6, padding: '6px 14px', color: 'var(--accent)', fontSize: 12, textDecoration: 'none', fontFamily: 'monospace' }}>
</a>
</div>
))}
</div>
)
}
{/* Add Website Modal */}
{showAdd && (
<ServiceModal
onClose={() => setShowAdd(false)}
onAdded={() => { load(); setShowAdd(false) }}
/>
)}
{showAdd && <ServiceModal onClose={() => setShowAdd(false)} onAdded={() => { load(); setShowAdd(false) }} />}
{/* Edit Website Modal */}
{editingService && (
<ServiceModal
service={editingService}
@@ -95,9 +163,6 @@ function ServiceModal({ service, onClose, onAdded }: { service?: Service; onClos
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', boxSizing: 'border-box' }
const lbl: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }
const presets = [
{ name: 'Coolify', url: 'http://localhost:8000', icon: '⚙️', description: 'Deploy yönetimi' },
{ name: 'Grafana', url: 'http://localhost:3000', icon: '📊', description: 'Metrik görselleştirme' },
@@ -106,40 +171,63 @@ function ServiceModal({ service, onClose, onAdded }: { service?: Service; onClos
]
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 }}>{service ? 'Servis Düzenle' : 'Servis Ekle'}</div>
<div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[420px] shadow-2xl animate-in fade-in zoom-in-95 duration-200 bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/80">
<CardTitle className="text-white text-lg font-bold">{service ? 'Servis Düzenle' : 'Servis Ekle'}</CardTitle>
<CardDescription className="text-muted/80">Harici portal bağlantısının detaylarını girin.</CardDescription>
</CardHeader>
<CardContent className="pt-6 space-y-4">
{/* Quick presets pills */}
{!service && (
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Hızlı Ekle Presets</label>
<div className="flex flex-wrap gap-1.5">
{presets.map(p => (
<button
key={p.name}
onClick={() => setF(p)}
className="text-[11px] font-mono px-2.5 py-1 rounded bg-black/20 hover:bg-black/45 border border-border/80 transition-colors text-[#e2e8f0]"
>
{p.icon} {p.name}
</button>
))}
</div>
</div>
)}
{!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>
))}
{/* Form fields */}
<div className="grid grid-cols-[80px_1fr] gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">İkon</label>
<Input value={f.icon} onChange={e => setF(p => ({ ...p, icon: e.target.value }))} className="bg-black/20 border-[#1d2639] text-center text-lg" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Ad</label>
<Input placeholder="Coolify" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
</div>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '50px 1fr', gap: 8 }}>
<div><label style={lbl}>İkon</label><input style={inp} value={f.icon} onChange={e => setF(p => ({ ...p, icon: e.target.value }))} /></div>
<div><label style={lbl}>Ad</label><input style={inp} placeholder="Coolify" 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">URL</label>
<Input value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} className="bg-black/20 border-[#1d2639] font-mono text-sm" />
</div>
<div><label style={lbl}>URL</label><input style={inp} value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} /></div>
<div><label style={lbl}>Açıklama</label><input style={inp} placeholder="Deploy yönetimi" value={f.description} onChange={e => setF(p => ({ ...p, description: e.target.value }))} /></div>
</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} 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 ? '...' : service ? 'Güncelle' : 'Ekle'}
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Açıklama</label>
<Input placeholder="Deploy yönetimi" value={f.description} onChange={e => setF(p => ({ ...p, description: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
</div>
<div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]">İptal</Button>
<Button onClick={submit} disabled={loading || !f.name} className="flex-1 bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold">
{loading ? 'Yükleniyor...' : service ? 'Kaydet' : 'Ekle'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+336 -147
View File
@@ -1,14 +1,18 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Bell } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Plus, X, Bell, Activity, Clock, Zap, CheckCircle2, ShieldAlert,
Trash2, Edit2, Loader2, ArrowUpRight, HelpCircle
} 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)'
export default function UptimePage() {
const [sites, setSites] = useState<Site[]>([])
const [selected, setSelected] = useState<Site | null>(null)
@@ -21,13 +25,14 @@ export default function UptimePage() {
const loadSites = useCallback(async () => {
const r = await fetch('/api/config')
const cfg = await r.json()
setSites(cfg.sites)
setSites(cfg.sites || [])
}, [])
useEffect(() => { loadSites() }, [loadSites])
const selectSite = async (site: Site) => {
setSelected(site); setData(null)
setSelected(site)
setData(null)
const r = await fetch(`/api/ping?siteId=${site.id}`)
setData(await r.json())
}
@@ -49,119 +54,274 @@ export default function UptimePage() {
loadSites()
}
const sc = (s: string | null) =>
s === 'up' ? 'text-[#00e676]' : s === 'down' ? 'text-[#ff3d71]' : s === 'degraded' ? 'text-[#ffab00]' : 'text-muted'
const scBg = (s: string | null) =>
s === 'up' ? 'bg-[#00e676]' : s === 'down' ? 'bg-[#ff3d71]' : s === 'degraded' ? 'bg-[#ffab00]' : 'bg-muted'
const last50 = (data?.logs ?? []).slice(0, 50).reverse()
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
{/* LEFT */}
<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>
<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 className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* Uptimus Top Header Bar */}
<header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 text-accent bg-accent/10 border border-accent/20 rounded-lg flex items-center justify-center p-1.5 shadow-[0_0_12px_rgba(0,229,255,0.1)]">
<Activity className="w-full h-full text-accent animate-pulse" />
</div>
<span className="font-extrabold text-base tracking-tight text-white select-none">Uptimus</span>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
{sites.length === 0
? <div style={{ padding: 20, color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace', textAlign: 'center' }}>Henüz site yok</div>
: sites.map(site => (
<div key={site.id} onClick={() => selectSite(site)} style={{ padding: '10px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 4, background: selected?.id === site.id ? 'rgba(255,255,255,.04)' : 'transparent', border: `1px solid ${selected?.id === site.id ? 'var(--border)' : 'transparent'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{site.name}</span>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={e => { e.stopPropagation(); setEditingSite(site) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer' }}></button>
<button onClick={e => { e.stopPropagation(); deleteSite(site.id) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer' }}></button>
</div>
</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site.url}</div>
</div>
))}
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setShowNotif(true)}
className="h-8 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0] px-3 gap-1.5"
title="Bildirim Ayarları"
>
<Bell size={13} />
<span>Alerts</span>
</Button>
<span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
Community
</span>
</div>
</div>
</header>
{/* RIGHT */}
<div style={{ flex: 1, overflow: 'auto' }}>
{!selected
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}> Site seç</div>
: (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
{/* Content Layout */}
<div className="flex flex-1 overflow-hidden">
{/* Left Panel: Site Cards list */}
<aside className="w-80 bg-[#0b0f19] border-r border-border flex flex-col shrink-0">
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{sites.length === 0 ? (
<div className="py-8 text-center text-muted font-mono text-xs">
No sites monitored
</div>
) : (
sites.map(site => {
const isSelected = selected?.id === site.id
return (
<div
key={site.id}
onClick={() => selectSite(site)}
className={`p-4 rounded-xl cursor-pointer transition-all duration-200 border relative group ${
isSelected
? 'bg-[#141b2a] border-accent/60 shadow-[0_0_15px_rgba(0,229,255,0.04)] ring-1 ring-accent/20'
: 'bg-[#131926] border-[#1d2639] hover:border-accent/40'
}`}
>
{/* Header */}
<div className="flex items-center justify-between gap-2 mb-2 pr-10">
<span className="text-sm font-bold text-white truncate tracking-tight">{site.name}</span>
</div>
{/* URL */}
<div className="text-[11px] text-muted/80 font-mono truncate mb-2">
{site.url}
</div>
{/* Meta */}
<div className="text-[10px] text-muted/60 font-mono flex justify-between items-center">
<span>Interval: {site.interval_min}m</span>
</div>
{/* Floating Actions on Hover */}
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
<button
onClick={e => { e.stopPropagation(); setEditingSite(site) }}
className="p-1 bg-[#1a1f2e] hover:bg-[#252c3e] text-muted hover:text-white rounded border border-[#1d2639]"
title="Düzenle"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={e => { e.stopPropagation(); deleteSite(site.id) }}
className="p-1 bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white rounded border border-red-500/20"
title="Sil"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})
)}
</div>
{/* Add Site Button */}
<div className="p-4 border-t border-border bg-[#0a0d16] shrink-0">
<Button
onClick={() => setShowAdd(true)}
className="w-full bg-[#1a56db] hover:bg-[#1a56db]/90 text-white font-semibold py-2.5 rounded-xl border border-transparent transition-all duration-200 flex items-center justify-center gap-1.5 shadow-md text-xs uppercase tracking-wider"
>
<Plus className="w-4 h-4" /> Add website
</Button>
</div>
</aside>
{/* Right Panel: Selected Site Monitoring Details */}
<main className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#070a13] relative">
{!selected ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted font-mono text-sm p-4 text-center">
<Activity className="w-10 h-10 mb-4 text-muted/40 animate-pulse" />
<div className="max-w-xs text-xs font-semibold leading-relaxed">
Select a website from the sidebar to view latency charts, health checks logs, and configure downtime alerts.
</div>
</div>
) : (
<div className="max-w-5xl mx-auto fade-up space-y-8">
{/* Site detail title header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-border/40 pb-6">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<div style={{ width: 9, height: 9, borderRadius: '50%', background: sc(data?.last?.status ?? null), boxShadow: `0 0 8px ${sc(data?.last?.status ?? null)}` }} />
<h2 style={{ fontSize: 18, fontWeight: 800 }}>{selected.name}</h2>
<div className="flex items-center gap-2.5 mb-2">
<span className="relative flex h-3 w-3 shrink-0">
{data?.last?.status === 'up' && (
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#00e676] opacity-75"></span>
)}
<span className={`relative inline-flex rounded-full h-3 w-3 ${scBg(data?.last?.status ?? null)}`}></span>
</span>
<h2 className="text-2xl font-extrabold text-white tracking-tight">{selected.name}</h2>
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>{selected.url}</div>
<a href={selected.url} target="_blank" rel="noreferrer" className="text-xs text-accent hover:underline font-mono flex items-center gap-1">
{selected.url} <ArrowUpRight className="w-3 h-3" />
</a>
</div>
<button onClick={ping} disabled={pinging} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: '8px 18px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
{pinging ? '...' : '▶ Ping At'}
</button>
<Button
onClick={ping}
disabled={pinging}
className="bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold text-xs uppercase tracking-wider py-2 px-5 rounded-xl shadow"
>
{pinging ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
'▶ Run check now'
)}
</Button>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12, marginBottom: 24 }}>
{/* Stats metric cards row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
{[
{ l: 'Son Durum', v: data?.last?.status?.toUpperCase() ?? '—', c: sc(data?.last?.status ?? null) },
{ l: 'Response', v: data?.last?.ms ? `${data.last.ms}ms` : '—', c: 'var(--text)' },
{ l: 'Uptime 24s', v: data ? `${data.uptime24}%` : '—', c: 'var(--green)' },
{ l: 'Uptime 7g', v: data ? `${data.uptime7d}%` : '—', c: 'var(--green)' },
{ label: 'Current Status', value: data?.last?.status?.toUpperCase() ?? '—', c: sc(data?.last?.status ?? null), icon: Activity },
{ label: 'Latency Response', value: data?.last?.ms ? `${data.last.ms}ms` : '—', c: 'text-white', icon: Zap },
{ label: 'Uptime 24h', value: data ? `${data.uptime24}%` : '—', c: 'text-[#00e676]', icon: CheckCircle2 },
{ label: 'Uptime 7d', value: data ? `${data.uptime7d}%` : '—', c: 'text-[#00e676]', icon: CheckCircle2 },
].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: s.c }}>{s.v}</div>
<div key={s.label} className="bg-[#0b0f19] border border-[#1d2639] rounded-xl p-5 shadow">
<div className="flex justify-between items-start mb-3">
<span className="text-[10px] text-muted font-mono uppercase tracking-wider">{s.label}</span>
<s.icon className="w-4 h-4 text-muted/30" />
</div>
<div className={`text-xl font-extrabold tracking-tight ${s.c}`}>{s.value}</div>
</div>
))}
</div>
{/* Status bar */}
{/* Status checks timeline bar */}
{last50.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Son {last50.length} kontrol</div>
<div style={{ display: 'flex', gap: 2, height: 28 }}>
{last50.map((log, i) => (
<div key={i} title={`${log.status}${log.ms}ms • ${new Date(log.ts * 1000).toLocaleString('tr-TR')}`}
style={{ flex: 1, borderRadius: 2, background: sc(log.status), opacity: .5 + (i / last50.length) * .5, cursor: 'help' }} />
))}
<div className="space-y-4">
<h3 className="text-xs text-muted font-mono uppercase tracking-widest">Recent Checks Timeline</h3>
<div className="bg-[#0b0f19] border border-border/80 rounded-xl p-5">
<div className="flex gap-1.5 h-7">
{last50.map((log, i) => (
<div
key={i}
title={`${log.status.toUpperCase()}${log.ms ? `${log.ms}ms` : 'No response'}${new Date(log.ts * 1000).toLocaleString('tr-TR')}`}
className={`flex-1 rounded-[3px] transition-all hover:scale-110 cursor-help ${scBg(log.status)}`}
style={{ opacity: 0.4 + (i / last50.length) * 0.6 }}
/>
))}
</div>
<div className="flex justify-between mt-2 text-[10px] font-mono text-muted/60">
<span>{last50.length} checks ago</span>
<span>just now</span>
</div>
</div>
</div>
)}
{/* Log table */}
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Log</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{!data ? (
<div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Yükleniyor...</div>
) : data.logs.length === 0 ? (
<div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Henüz log yok Ping At butonuna bas</div>
) : (
<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>
</thead>
<tbody>
{data.logs.slice(0, 50).map((log, i) => (
<tr key={i}>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', color: 'var(--muted)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{new Date(log.ts * 1000).toLocaleString('tr-TR')}</td>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', color: sc(log.status), fontWeight: 700, borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.status?.toUpperCase()}</td>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.ms ?? '—'}</td>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.code ?? '—'}</td>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', color: 'var(--red)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.error ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
{/* Ping check Log table */}
<div className="space-y-4">
<h3 className="text-xs text-muted font-mono uppercase tracking-widest">Check History Logs</h3>
<div className="bg-[#0b0f19] border border-border/80 rounded-xl overflow-hidden shadow-xl">
{!data ? (
<div className="p-8 text-center text-muted font-mono text-xs animate-pulse">
Loading latency logs...
</div>
) : data.logs.length === 0 ? (
<div className="p-8 text-center text-muted font-mono text-xs">
No logs recorded yet. Click "Run check now" to ping.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-[#0e1422]">
{['Timestamp', 'Status', 'Response', 'HTTP Code', 'Error details'].map(h => (
<th key={h} className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-muted/80 border-b border-border/80">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{data.logs.slice(0, 50).map((log, i) => (
<tr key={i} className="border-b border-border/40 hover:bg-white/[0.01] transition-colors duration-150">
<td className="px-6 py-3.5 text-sm font-mono text-muted/80">
{new Date(log.ts * 1000).toLocaleString('tr-TR')}
</td>
<td className="px-6 py-3.5">
<span className={`text-xs font-bold ${sc(log.status)}`}>
{log.status?.toUpperCase()}
</span>
</td>
<td className="px-6 py-3.5 text-sm font-mono text-white/90">
{log.ms ? `${log.ms} ms` : '—'}
</td>
<td className="px-6 py-3.5 text-sm font-mono text-white/90">
{log.code ?? '—'}
</td>
<td className="px-6 py-3.5 text-xs font-mono text-[#ff3d71] max-w-[240px] truncate" title={log.error || ''}>
{log.error ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
</main>
</div>
{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)} />}
{/* Add Website Modal */}
{showAdd && (
<AddSiteModal
onClose={() => setShowAdd(false)}
onAdded={() => { loadSites(); setShowAdd(false) }}
/>
)}
{/* Edit Website Modal */}
{editingSite && (
<AddSiteModal
site={editingSite}
onClose={() => setEditingSite(null)}
onAdded={() => { loadSites(); setEditingSite(null); if (selected?.id === editingSite.id) selectSite(editingSite) }}
/>
)}
{/* Alert Notifications Modal */}
{showNotif && (
<NotifModal onClose={() => setShowNotif(false)} />
)}
</div>
)
}
@@ -186,25 +346,35 @@ function AddSiteModal({ site, onClose, onAdded }: { site?: Site; onClose: () =>
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: 400 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 20 }}>{site ? 'Site Düzenle' : 'Yeni Site Ekle'}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div><label style={lbl}>Ad</label><input style={inp} placeholder="Kotekli Asistan" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} /></div>
<div><label style={lbl}>URL</label><input style={inp} placeholder="https://example.com" value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} /></div>
<div><label style={lbl}>Kontrol Aralığı (dk)</label><input style={inp} type="number" value={f.interval_min} onChange={e => setF(p => ({ ...p, interval_min: parseInt(e.target.value) }))} /></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 }}>İptal</button>
<button onClick={submit} disabled={loading || !f.name || !f.url} 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...' : site ? 'Kaydet' : 'Ekle & Ping At'}
</button>
</div>
</div>
<div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[420px] shadow-2xl animate-in fade-in zoom-in-95 duration-200 bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/80">
<CardTitle className="text-white text-lg font-bold">{site ? 'Site Düzenle' : 'Yeni Site Ekle'}</CardTitle>
<CardDescription className="text-muted/80">İzlemek istediğiniz web sitesinin detaylarını 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">İsim</label>
<Input placeholder="Örn: Portföy Sitem" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">URL</label>
<Input placeholder="https://example.com" value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Kontrol Aralığı (Dakika)</label>
<Input type="number" value={f.interval_min} onChange={e => setF(p => ({ ...p, interval_min: parseInt(e.target.value) || 5 }))} className="bg-black/20 border-[#1d2639]" />
</div>
<div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]">İptal</Button>
<Button onClick={submit} disabled={loading || !f.name || !f.url} className="flex-1 bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold">
{loading ? 'Kaydediliyor...' : site ? 'Kaydet' : 'Ekle & Ping At'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -230,48 +400,67 @@ function NotifModal({ onClose }: { onClose: () => void }) {
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 className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[480px] shadow-2xl bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/80 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-white text-lg font-bold">Bildirim Ayarları</CardTitle>
<CardDescription className="text-muted/80">Downtime bildirimleri için hedefler tanımlayın.</CardDescription>
</div>
)}
<label className="flex items-center gap-2 cursor-pointer text-xs font-mono text-muted select-none">
<input type="checkbox" checked={f.enabled} onChange={e => setF(p => ({ ...p, enabled: e.target.checked }))} className="rounded border-[#1d2639] bg-black/20 w-4 h-4 accent-accent" />
<span>Etkin</span>
</label>
</CardHeader>
<CardContent className="pt-6 space-y-4">
{loading ? (
<div className="text-center font-mono text-xs text-muted py-6">Yükleniyor...</div>
) : (
<div className="space-y-4">
<div>
<div className="text-xs font-bold text-accent mb-2">Webhook Bildirimleri</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Webhook URL (Slack / Discord)</label>
<Input placeholder="https://hooks.slack.com/... veya discord webhook" value={f.webhook_url} onChange={e => setF(p => ({ ...p, webhook_url: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
</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 className="border-t border-border/50 pt-4">
<div className="text-xs font-bold text-accent mb-2">Telegram Bot Bildirimleri</div>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bot Token</label>
<Input placeholder="123456:ABC-DEF..." value={f.telegram_token} onChange={e => setF(p => ({ ...p, telegram_token: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Chat ID</label>
<Input placeholder="-100xxxxxxxxx veya @channel" value={f.telegram_chat_id} onChange={e => setF(p => ({ ...p, telegram_chat_id: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
</div>
</div>
<div className="mt-3 text-[10px] font-mono text-muted/60 leading-normal">
@BotFather'dan bot oluşturup token alın, botu grubunuza ekleyerek chat_id değerini girin.
</div>
</div>
</div>
)}
<div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0]">Kapat</Button>
<Button
onClick={save}
disabled={saving || loading}
className={`flex-1 font-bold text-xs uppercase tracking-wider ${
saved
? 'bg-emerald-500 hover:bg-emerald-600 text-white'
: 'bg-accent hover:bg-accent/90 text-[#0d0f14]'
}`}
>
{saving ? 'Kaydediliyor...' : saved ? ' Kaydedildi' : 'Kaydet'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+17 -7
View File
@@ -36,8 +36,10 @@ pool.query(`
CREATE TABLE IF NOT EXISTS analytics_sites (
domain TEXT PRIMARY KEY,
name TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
ALTER TABLE analytics_sites ADD COLUMN IF NOT EXISTS name TEXT;
CREATE TABLE IF NOT EXISTS config_sites (
id TEXT PRIMARY KEY,
@@ -96,6 +98,7 @@ pool.query(`
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE INDEX IF NOT EXISTS idx_backup_db_ts ON backup_logs(db_id, ts DESC);
ALTER TABLE backup_logs ADD COLUMN IF NOT EXISTS duration_sec INTEGER;
CREATE TABLE IF NOT EXISTS notification_settings (
id TEXT PRIMARY KEY DEFAULT 'default',
@@ -246,20 +249,20 @@ export async function getAnalyticsStats(domain: string, days = 30) {
return { total, unique, topPages, topReferrers, byDevice, byBrowser, byOs, byCountry, daily }
}
export async function getAllDomains(): Promise<string[]> {
export async function getAllDomains(): Promise<{ domain: string; name: string | null }[]> {
const res = await pool.query(`
SELECT domain FROM analytics_sites
SELECT domain, name FROM analytics_sites
UNION
SELECT DISTINCT domain FROM pageviews
SELECT DISTINCT domain, NULL as name FROM pageviews WHERE domain NOT IN (SELECT domain FROM analytics_sites)
ORDER BY domain
`)
return res.rows.map(r => r.domain)
return res.rows.map(r => ({ domain: r.domain, name: r.name }))
}
export async function addAnalyticsSite(domain: string) {
export async function addAnalyticsSite(domain: string, name?: string) {
await pool.query(
`INSERT INTO analytics_sites (domain) VALUES ($1) ON CONFLICT DO NOTHING`,
[domain]
`INSERT INTO analytics_sites (domain, name) VALUES ($1, $2) ON CONFLICT (domain) DO UPDATE SET name = EXCLUDED.name`,
[domain, name || null]
)
}
@@ -269,6 +272,13 @@ export async function renameAnalyticsSite(oldDomain: string, newDomain: string)
await pool.query(`UPDATE pageviews SET domain = $1 WHERE domain = $2`, [newDomain, oldDomain])
}
export async function updateAnalyticsSiteName(domain: string, name: string | null) {
await pool.query(
`INSERT INTO analytics_sites (domain, name) VALUES ($1, $2) ON CONFLICT (domain) DO UPDATE SET name = EXCLUDED.name`,
[domain, name]
)
}
export async function deleteAnalyticsSite(domain: string) {
await pool.query(`DELETE FROM analytics_sites WHERE domain = $1`, [domain])
await pool.query(`DELETE FROM pageviews WHERE domain = $1`, [domain])
+8 -4
View File
@@ -54,22 +54,26 @@ export async function reloadBackupCrons() {
: row.cloud_type
console.log(`[Backup] ${db.name} (${effectiveType}) başlatılıyor...`)
const backupStart = Date.now()
try {
const buffer = await createDbDumpBuffer(db)
const filename = effectiveType === 'gdrive' ? backupFilename() : backupFilename(db.name)
await runUpload(row, db, buffer, filename)
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db.id, 'success', 'Otomatik yedek', buffer.length]
`INSERT INTO backup_logs (db_id, status, message, file_size, duration_sec) VALUES ($1, $2, $3, $4, $5)`,
[db.id, 'success', 'Otomatik yedek', buffer.length, durationSec]
)
console.log(`[Backup] ${db.name} tamamlandı.`)
} catch (e: any) {
console.error(`[Backup] Hata (${db.name}):`, e)
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`,
[db.id, 'error', e.message || String(e), 0]
`INSERT INTO backup_logs (db_id, status, message, file_size, duration_sec) VALUES ($1, $2, $3, $4, $5)`,
[db.id, 'error', e.message || String(e), 0, durationSec]
)
}
})