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 { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth' 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) { export async function GET(req: NextRequest) {
const err = await requireAuth(req) const err = await requireAuth(req)
@@ -9,24 +9,29 @@ export async function GET(req: NextRequest) {
const domain = req.nextUrl.searchParams.get('domain') const domain = req.nextUrl.searchParams.get('domain')
const days = parseInt(req.nextUrl.searchParams.get('days') ?? '30') const days = parseInt(req.nextUrl.searchParams.get('days') ?? '30')
if (!domain) { try {
// Domain listesi döndür if (!domain) {
const domains = await getAllDomains() // Domain listesi döndür
return NextResponse.json({ domains }) const domains = await getAllDomains()
} return NextResponse.json({ domains })
}
const stats = await getAnalyticsStats(domain, days) const stats = await getAnalyticsStats(domain, days)
return NextResponse.json(stats) 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) { export async function POST(req: NextRequest) {
const err = await requireAuth(req) const err = await requireAuth(req)
if (err) return err 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 }) if (!domain) return NextResponse.json({ error: 'Domain required' }, { status: 400 })
await addAnalyticsSite(domain) await addAnalyticsSite(domain, name)
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
} }
@@ -34,10 +39,19 @@ export async function PUT(req: NextRequest) {
const err = await requireAuth(req) const err = await requireAuth(req)
if (err) return err if (err) return err
const { oldDomain, newDomain } = await req.json() const { oldDomain, newDomain, name } = await req.json()
if (!oldDomain || !newDomain) return NextResponse.json({ error: 'Missing params' }, { status: 400 })
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 }) 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) { export async function DELETE(req: NextRequest) {
const err = await requireAuth(req) const err = await requireAuth(req)
if (err) return err if (err) return err
try { try {
const body = await req.json() const body = await req.json()
const { db_id } = body const { db_id, logId } = body
if (!db_id) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
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 pool.query(`DELETE FROM backup_configs WHERE db_id = $1`, [db_id])
await reloadBackupCrons() 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 filename = type === 'gdrive' ? backupFilename() : backupFilename(db.name)
const backupStart = Date.now()
try { try {
const buffer = await createDbDumpBuffer(db) 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ü.' }) return NextResponse.json({ ok: false, error: 'Desteklenmeyen cloud türü.' })
} }
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query( await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, `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] [db_id, 'success', `Manuel yedek — ${type}`, buffer.length, durationSec]
) )
const sizeStr = buffer.length > 1024 * 1024 const sizeStr = buffer.length > 1024 * 1024
@@ -58,7 +61,8 @@ export async function POST(req: NextRequest) {
const pathStr = type === 'gdrive' ? `${db.name}/${filename}` : filename const pathStr = type === 'gdrive' ? `${db.name}/${filename}` : filename
return NextResponse.json({ ok: true, detail: `✓ Yüklendi — ${pathStr} (${sizeStr})` }) return NextResponse.json({ ok: true, detail: `✓ Yüklendi — ${pathStr} (${sizeStr})` })
} catch (e: any) { } 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 }) 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) const db = config.databases.find(d => d.id === dbId)
if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 }) if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
const backupStart = Date.now()
try { try {
const buffer = await createDbDumpBuffer(db) const buffer = await createDbDumpBuffer(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-') const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql` const filename = `${db.name}_${dateStr}.sql`
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query( await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, `INSERT INTO backup_logs (db_id, status, message, file_size, duration_sec) VALUES ($1, $2, $3, $4, $5)`,
[dbId, 'success', 'Manuel indirme', buffer.length] [dbId, 'success', 'Manuel indirme', buffer.length, durationSec]
) )
return new NextResponse(new Uint8Array(buffer), { return new NextResponse(new Uint8Array(buffer), {
@@ -34,9 +36,10 @@ export async function GET(req: NextRequest) {
}, },
}) })
} catch (e: any) { } catch (e: any) {
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query( await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, `INSERT INTO backup_logs (db_id, status, message, file_size, duration_sec) VALUES ($1, $2, $3, $4, $5)`,
[dbId, 'error', e.message, 0] [dbId, 'error', e.message, 0, durationSec]
) )
return NextResponse.json({ error: e.message }, { status: 500 }) 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { AnimatedTabs } from '@/components/ui/animated-tabs' 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 = { type Stats = {
total: number total: number
@@ -25,25 +25,27 @@ const DAYS_OPTIONS = [
{ id: '90', label: '90G' }, { 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 const pct = max > 0 ? (value / max) * 100 : 0
return ( return (
<div className="mb-3"> <div className="mb-4">
<div className="flex justify-between mb-1.5"> <div className="flex justify-between mb-1.5 font-mono text-xs">
<span className="text-xs text-text font-mono truncate flex-1 mr-2">{label}</span> <span className="text-[#e2e8f0]/90 truncate flex-1 mr-2">{label}</span>
<span className="text-xs text-muted font-mono shrink-0">{value.toLocaleString()}</span> <span className="text-muted shrink-0 font-bold">{value.toLocaleString()}</span>
</div> </div>
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden"> <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}%`, background: color }} /> <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>
</div> </div>
) )
} }
function MiniChart({ data, days }: { data: { day: string; views: number; visitors: number }[]; days: number }) { 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 filled: { day: string; views: number; visitors: number }[] = []
const now = new Date() const now = new Date()
for (let i = days - 1; i >= 0; i--) { 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) const maxViews = Math.max(...filled.map(d => d.views), 1)
return ( 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) => ( {filled.map((d, i) => (
<div <div
key={i} 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 <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={{ style={{
height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 4 : 0)}%`, height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 5 : 0)}%`,
minHeight: d.views > 0 ? 2 : 0 minHeight: d.views > 0 ? 2 : 0,
boxShadow: d.views > 0 ? '0 0 8px rgba(0,229,255,0.2)' : 'none'
}} }}
/> />
</div> </div>
@@ -77,30 +85,42 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
} }
export default function AnalyticsPage() { 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 [selectedDomain, setSelectedDomain] = useState<string | null>(null)
const [days, setDays] = useState('30') const [days, setDays] = useState('30')
const [stats, setStats] = useState<Stats | null>(null) const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showSnippet, setShowSnippet] = useState(false) const [showSnippet, setShowSnippet] = useState(false)
const [showAddSite, setShowAddSite] = 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 [panelUrl, setPanelUrl] = useState('')
const [copied, setCopied] = useState(false)
const loadDomains = useCallback(() => { const loadDomains = useCallback(() => {
fetch('/api/analytics/stats') 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 => { .then(d => {
const dms = d.domains ?? [] const dms = d.domains ?? []
setDomains(dms) setDomains(dms)
if (dms.length > 0) { if (dms.length > 0) {
if (!selectedDomain || !dms.includes(selectedDomain)) { // Check if we still have the selected domain
setSelectedDomain(dms[0]) const match = dms.find((x: any) => x.domain === selectedDomain)
if (!selectedDomain || !match) {
setSelectedDomain(dms[0].domain)
} }
} else { } else {
setSelectedDomain(null) setSelectedDomain(null)
} }
}) })
.catch(err => {
console.error('Error loading domains:', err)
})
}, [selectedDomain]) }, [selectedDomain])
useEffect(() => { useEffect(() => {
@@ -111,36 +131,27 @@ export default function AnalyticsPage() {
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
if (!selectedDomain) return if (!selectedDomain) return
setLoading(true) setLoading(true)
const r = await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}&days=${days}`) try {
setStats(await r.json()) 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) setLoading(false)
}, [selectedDomain, days]) }, [selectedDomain, days])
useEffect(() => { loadStats() }, [loadStats]) useEffect(() => { loadStats() }, [loadStats])
const handleRename = async () => { const handleDelete = async (dName: string) => {
if (!selectedDomain) return if (!window.confirm(`'${dName}' sitesini ve tüm analiz verilerini SİLMEK istediğinize emin misiniz? Bu işlem geri alınamaz!`)) 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
setLoading(true) setLoading(true)
await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}`, { method: 'DELETE' }) await fetch(`/api/analytics/stats?domain=${encodeURIComponent(dName)}`, { method: 'DELETE' })
setSelectedDomain(null) if (selectedDomain === dName) setSelectedDomain(null)
setTimeout(loadDomains, 100) setTimeout(loadDomains, 100)
} }
@@ -148,212 +159,290 @@ export default function AnalyticsPage() {
const snippet = `<!-- VPS Panel Analytics --> const snippet = `<!-- VPS Panel Analytics -->
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>` <script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
const selectedSite = domains.find(d => d.domain === selectedDomain)
return ( return (
<div className="p-8 max-w-[1200px] mx-auto fade-up"> <div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* Header */} {/* Analyticus Top Header Bar */}
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-8"> <header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div> <div className="flex items-center gap-2.5">
<h1 className="text-2xl font-extrabold tracking-tight mb-1">Analytics</h1> <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)]">
<p className="text-xs text-muted font-mono tracking-wide">Cookie-free, self-hosted website tracking</p> <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>
<div className="flex flex-wrap items-center gap-3"> <div className="flex items-center gap-3">
{/* Domain Actions */} <Button
{selectedDomain && ( variant="default"
<div className="flex items-center gap-2 bg-surface-2 p-1 border border-border/50 rounded-xl"> size="sm"
<Button variant="ghost" size="sm" onClick={handleRename} className="h-8 px-3 text-xs"> onClick={() => setShowSnippet(true)}
<Edit2 className="w-3.5 h-3.5 mr-1.5" /> Düzenle 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"
</Button> >
<div className="w-px h-4 bg-border" /> <Code className="w-3.5 h-3.5" /> Snippet
<Button variant="ghost" size="sm" onClick={handleDelete} className="h-8 px-3 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"> </Button>
<Trash2 className="w-3.5 h-3.5 mr-1.5" /> Sil <span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
</Button> 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> </div>
)} )}
</main>
<Button variant="outline" size="sm" onClick={() => setShowAddSite(true)} className="h-10">
<Plus className="w-4 h-4 mr-1.5" /> Site Ekle
</Button>
<Button variant="default" size="sm" onClick={() => setShowSnippet(true)} className="h-10 bg-purple-500/10 text-purple-400 border-purple-500/30 hover:bg-purple-500/20">
<Code className="w-4 h-4 mr-1.5" /> Snippet
</Button>
{/* Days filter - using AnimatedTabs */}
<div className="ml-2">
<AnimatedTabs
activeTab={days}
onChange={(id) => setDays(id)}
tabs={DAYS_OPTIONS}
/>
</div>
</div>
</div> </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 */} {/* Snippet modal */}
{showSnippet && ( {showSnippet && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowSnippet(false)}> <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" onClick={e => e.stopPropagation()}> <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> <CardHeader className="pb-4 border-b border-border/80">
<CardTitle>Tracking Snippet</CardTitle> <CardTitle className="text-white text-lg font-bold">Tracking Snippet</CardTitle>
<CardDescription> <CardDescription className="text-muted/80">
Sitendeki <code className="bg-black/30 px-1.5 py-0.5 rounded border border-border">{'<head>'}</code> tagının içine ekle: 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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="pt-6 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"> <div className="relative group">
{snippet} <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">
</pre> {snippet}
</pre>
<div className="space-y-2 text-xs text-muted font-mono bg-surface-2 p-3 rounded-lg border border-border/50">
<div><span className="text-success font-semibold">data-domain</span> — hangi domain olduğunu belirtir, değiştirme</div>
<div><span className="text-success font-semibold">defer</span> — sayfayı yavaşlatmaz, arka planda yüklenir</div>
</div> </div>
<div className="flex gap-3 pt-2"> <div className="space-y-2 text-xs text-muted/70 font-mono bg-black/10 p-3 rounded-lg border border-border/50">
<Button onClick={() => { navigator.clipboard.writeText(snippet); }} className="flex-1"> <div><span className="text-accent font-bold">data-domain</span> hangi alan adı olduğunu belirtir, değiştirmeyin.</div>
Kopyala <div><span className="text-accent font-bold">defer</span> sayfa yüklenmesini yavaşlatmaz, asenkron yüklenir.</div>
</Button> </div>
<Button variant="ghost" onClick={() => setShowSnippet(false)} className="flex-1">
Kapat <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> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -361,63 +450,103 @@ export default function AnalyticsPage() {
</div> </div>
)} )}
{/* Add Site modal */} {/* Add / Edit Site modal */}
{showAddSite && ( {(showAddSite || editingSite) && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAddSite(false)}> <AddSiteModal
<Card className="w-full max-w-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}> site={editingSite ?? undefined}
<CardHeader> onClose={() => { setShowAddSite(false); setEditingSite(null) }}
<CardTitle>Yeni Site Ekle</CardTitle> onSaved={() => { loadDomains(); setShowAddSite(false); setEditingSite(null) }}
<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>
)} )}
</div> </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' 'use client'
import { useState, useEffect, useCallback } from 'react' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' 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 } 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) { function isGdriveConnected(credentials: string) {
try { return !!JSON.parse(credentials).refresh_token } catch { return false } try { return !!JSON.parse(credentials).refresh_token } catch { return false }
@@ -42,80 +42,140 @@ export default function ConnectionsPage() {
}, [load]) }, [load])
const del = async (id: string) => { 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' }) await fetch(`/api/cloud-connections?id=${id}`, { method: 'DELETE' })
load() load()
} }
return ( return (
<div style={{ padding: 28, maxWidth: 900 }}> <div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}> {/* Connectus Top Header Bar */}
<div> <header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Cloud Bağlantıları</h1> <div className="flex items-center gap-2.5">
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Bir kere kur, tüm DB yedekleri için kullan</p> <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> </div>
<Button onClick={() => setShowAdd(true)} className="gap-1.5"><Plus className="w-4 h-4" /> Yeni Bağlantı</Button>
</div> </div>
{notice && ( {/* Add / Edit Connection Modal overlay */}
<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>
)}
{(showAdd || editConn) && ( {(showAdd || editConn) && (
<ConnModal <ConnModal
conn={editConn} conn={editConn}
@@ -152,89 +212,103 @@ function ConnModal({ conn, onClose, onSaved }: { conn: Conn | null; onClose: ()
} }
return ( 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="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-[520px] shadow-2xl" onClick={e => e.stopPropagation()}> <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/50"> <CardHeader className="pb-4 border-b border-border/80">
<CardTitle>{conn ? 'Bağlantı Düzenle' : 'Yeni Cloud Bağlantısı'}</CardTitle> <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> </CardHeader>
<CardContent className="pt-5 space-y-4"> <CardContent className="pt-6 space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bağlantı Adı</label> <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>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Tür</label> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Tür</label>
<div className="flex gap-2"> <div className="flex gap-2">
{(['gcs', 'gdrive', 'dropbox'] as const).map(t => ( {(['gcs', 'gdrive', 'dropbox'] as const).map(t => (
<button key={t} onClick={() => setType(t)} <button
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'}`}> 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]} {TYPE_LABELS[t]}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* GCS */} {/* GCS Fields */}
{type === 'gcs' && ( {type === 'gcs' && (
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Service Account JSON</label> <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>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Varsayılan Bucket</label> <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> </div>
</> </>
)} )}
{/* Dropbox */} {/* Dropbox Fields */}
{type === 'dropbox' && ( {type === 'dropbox' && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Access Token</label> <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..." /> <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-[10px] text-muted/60 font-mono">dropbox.com/developers Apps Generate token</div> <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> </div>
)} )}
{/* Google Drive */} {/* Google Drive Fields */}
{type === 'gdrive' && ( {type === 'gdrive' && (
<> <>
<div className="space-y-1.5"> <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 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> <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) }} <button
className="p-2 rounded border border-border hover:bg-surface-2 text-muted hover:text-text shrink-0 transition-colors"> onClick={() => { navigator.clipboard.writeText(redirectUri); setCopiedUri(true); setTimeout(() => setCopiedUri(false), 2000) }}
{copiedUri ? <CheckCircle2 className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />} 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> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client ID</label> <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>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Client Secret</label> <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> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Varsayılan Klasör ID (Opsiyonel)</label> <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>
<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"> <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">
Kaydet → ardından "Bağlan" butonuyla Google hesabını yetkilendir. İlk önce buradaki bilgileri kaydedin → ardından dışarıdaki "Bağlan" butonu ile Google hesabınızı yetkilendirin.
</div> </div>
</> </>
)} )}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button> <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"> <Button onClick={submit} disabled={saving || !name} className="flex-1 bg-accent hover:bg-accent/90 text-[#0d0f14] font-bold">
{saving ? 'Kaydediliyor...' : conn ? 'Güncelle' : 'Kaydet'} {saving ? 'Kaydediliyor...' : conn ? 'Kaydet' : 'Kaydet'}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
File diff suppressed because it is too large Load Diff
+259 -177
View File
@@ -1,9 +1,12 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' 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 DockerHost = { id: string; name: string; url: string }
type Container = { type Container = {
@@ -60,7 +63,6 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
const d = await r.json() const d = await r.json()
setError(d.error || 'Aksiyon başarısız') setError(d.error || 'Aksiyon başarısız')
} else { } else {
// Kısa bekleyip listeyi yenile
await new Promise(res => setTimeout(res, 800)) await new Promise(res => setTimeout(res, 800))
await select(selected) await select(selected)
} }
@@ -71,182 +73,259 @@ export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[
} }
const deleteHost = async (id: string) => { 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 }) }) await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', id }) })
if (selected?.id === id) setSelected(null) if (selected?.id === id) setSelected(null)
loadHosts() loadHosts()
} }
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* LEFT */} {/* Dockerus Top Header Bar */}
<div className="w-64 bg-surface border-r border-border flex flex-col shrink-0"> <header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div className="p-4 border-b border-border/50 flex justify-between items-center"> <div className="flex items-center gap-2.5">
<span className="text-xs font-mono text-muted uppercase tracking-widest">Docker Hosts</span> <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)]">
<Button variant="default" size="sm" onClick={() => setShowAdd(true)} className="h-7 text-xs"> <Box className="w-full h-full text-accent" />
<Plus className="w-3.5 h-3.5 mr-1" /> Ekle </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> </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>
<div className="flex-1 overflow-y-auto p-3 space-y-1 custom-scrollbar"> </header>
{hosts.length === 0
? <div className="p-5 text-muted text-xs font-mono text-center">Henüz Host yok</div>
: hosts.map(host => (
<div
key={host.id}
onClick={() => select(host)}
className={`p-3 rounded-xl cursor-pointer transition-all border ${
selected?.id === host.id
? 'bg-accent/5 border-accent/20'
: 'border-transparent hover:bg-surface-2 hover:border-border/50'
}`}
>
<div className="flex items-center gap-2 mb-1.5">
<Box className="w-4 h-4 text-accent" />
<span className="text-sm font-semibold flex-1 truncate">{host.name}</span>
<button onClick={e => { e.stopPropagation(); deleteHost(host.id) }} className="text-muted hover:text-destructive transition-colors shrink-0">
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex justify-between items-center text-[10px] text-muted font-mono pl-6">
<span className="truncate mr-2">{host.url}</span>
</div>
</div>
))}
</div>
</div>
{/* RIGHT */} {/* Main Two-Column Layout */}
<div className="flex-1 overflow-auto custom-scrollbar relative"> <div className="flex flex-1 overflow-hidden">
{!selected
? ( {/* Left Sidepane: Docker Host selector list */}
<div className="absolute inset-0 flex items-center justify-center text-muted font-mono text-sm opacity-50"> <aside className="w-80 bg-[#0b0f19] border-r border-border flex flex-col shrink-0">
<Box className="w-5 h-5 mr-3" /> <div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
Sol menüden bir Docker sunucusu seçin {hosts.length === 0 ? (
</div> <div className="py-8 text-center text-muted font-mono text-xs">
) No Docker hosts configured
: ( </div>
<div className="p-8 max-w-6xl mx-auto fade-up"> ) : (
<div className="mb-8 flex justify-between items-start"> hosts.map(host => {
<div> const isSelected = selected?.id === host.id
<div className="flex items-center gap-3 mb-2"> return (
<Box className="w-6 h-6 text-accent" /> <div
<h2 className="text-2xl font-extrabold tracking-tight">{selected.name}</h2> 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>
<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>
</div> </div>
<Button variant="outline" size="sm" onClick={() => select(selected)} disabled={loading}> <Button
<RotateCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> 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 Yenile
</Button> </Button>
</div> </div>
{/* Error messages */}
{error && ( {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"> <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" /> {error} <ShieldAlert className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div> </div>
)} )}
{/* Containers cards table list */}
{!error && ( {!error && (
<Card className="overflow-hidden"> <Card className="bg-[#0b0f19] border-border/80 shadow-xl overflow-hidden">
{loading && containers.length === 0 {loading && containers.length === 0 ? (
? <div className="p-6 text-muted font-mono text-sm text-center animate-pulse">Konteynerler yükleniyor...</div> <div className="p-8 text-center text-muted font-mono text-xs animate-pulse flex items-center justify-center gap-2">
: containers.length === 0 <Loader2 className="w-4 h-4 animate-spin text-accent" />
? <div className="p-6 text-muted font-mono text-sm text-center">Konteyner bulunamadı.</div> <span>Konteynerler sorgulanıyor...</span>
: <div className="overflow-x-auto"> </div>
<table className="w-full text-left border-collapse"> ) : containers.length === 0 ? (
<thead> <div className="p-8 text-center text-muted font-mono text-xs">
<tr> Aktif docker konteyneri bulunamadı.
{['İsim', 'İmaj', 'Durum', 'Portlar', 'İşlem'].map(h => ( </div>
<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} <div className="overflow-x-auto">
</th> <table className="w-full text-left border-collapse">
))} <thead>
</tr> <tr className="bg-[#0e1422]">
</thead> {['Konteyner İsim', 'İmaj', 'Durum', 'Port Eşleşmeleri', 'İşlemler'].map(h => (
<tbody> <th key={h} className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-muted/80 border-b border-border/80">
{containers.map((c, i) => { {h}
const name = c.Names?.[0]?.replace(/^\//, '') || 'Bilinmiyor' </th>
const isRunning = c.State === 'running' ))}
const isActing = actionLoading?.startsWith(c.Id) </tr>
return ( </thead>
<tr key={c.Id} className="group hover:bg-white/[0.02] transition-colors"> <tbody>
<td className={`px-6 py-4 text-sm font-semibold text-accent ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}> {containers.map((c, idx) => {
{name} const name = c.Names?.[0]?.replace(/^\//, '') || 'Unknown'
</td> const isRunning = c.State === 'running'
<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' : ''}`}> const isActing = actionLoading?.startsWith(c.Id)
{c.Image} return (
</td> <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-xs font-mono ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}> <td className="px-6 py-4 text-sm font-bold text-accent tracking-tight">
<div className="flex items-center gap-2"> {name}
<div className={`w-2 h-2 rounded-full ${isRunning ? 'bg-success shadow-[0_0_8px_var(--color-success)]' : 'bg-muted'}`} /> </td>
<span className={isRunning ? 'text-success' : 'text-muted'}>{c.Status}</span> <td className="px-6 py-4 text-xs font-mono text-muted/85 max-w-[200px] truncate" title={c.Image}>
</div> {c.Image}
</td> </td>
<td className={`px-6 py-4 text-[10px] font-mono text-muted ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}> <td className="px-6 py-4">
<div className="flex flex-wrap gap-1"> <div className="flex items-center gap-2.5">
{c.Ports?.map((p, idx) => ( <span className="relative flex h-2 w-2 shrink-0">
<span key={idx} className="bg-surface-2 px-1.5 py-0.5 rounded border border-border/50"> {isRunning && (
{p.PublicPort ? `${p.PublicPort}${p.PrivatePort}` : p.PrivatePort}/{p.Type} <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#00e676] opacity-75"></span>
</span> )}
))} <span className={`relative inline-flex rounded-full h-2 w-2 ${isRunning ? 'bg-[#00e676]' : 'bg-muted'}`}></span>
</div> </span>
</td> <span className={`text-xs font-bold font-mono ${isRunning ? 'text-[#00e676]' : 'text-muted'}`}>
<td className={`px-4 py-3 ${i !== containers.length - 1 ? 'border-b border-border/50' : ''}`}> {c.Status}
<div className="flex items-center gap-1"> </span>
{isRunning ? ( </div>
<> </td>
<button <td className="px-6 py-4">
onClick={() => containerAction(c.Id, 'stop')} <div className="flex flex-wrap gap-1">
disabled={!!isActing} {c.Ports?.map((p, pIdx) => (
title="Durdur" <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">
className="p-1.5 rounded-lg text-muted hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40" {p.PublicPort ? `${p.PublicPort}${p.PrivatePort}` : p.PrivatePort}/{p.Type}
> </span>
<Square className="w-3.5 h-3.5" /> ))}
</button> {(!c.Ports || c.Ports.length === 0) && (
<button <span className="text-xs text-muted/50 font-mono"></span>
onClick={() => containerAction(c.Id, 'restart')} )}
disabled={!!isActing} </div>
title="Yeniden Başlat" </td>
className="p-1.5 rounded-lg text-muted hover:text-warning hover:bg-warning/10 transition-colors disabled:opacity-40" <td className="px-6 py-4">
> <div className="flex items-center gap-1.5">
<RotateCw className={`w-3.5 h-3.5 ${actionLoading === c.Id + 'restart' ? 'animate-spin' : ''}`} /> {isRunning ? (
</button> <>
</>
) : (
<button
onClick={() => containerAction(c.Id, 'start')}
disabled={!!isActing}
title="Başlat"
className="p-1.5 rounded-lg text-muted hover:text-success hover:bg-success/10 transition-colors disabled:opacity-40"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
<button <button
onClick={() => setLogsModal({ containerId: c.Id, name })} onClick={() => containerAction(c.Id, 'stop')}
title="Logları Gör" disabled={!!isActing}
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-accent/10 transition-colors" 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> </button>
</div> <button
</td> onClick={() => containerAction(c.Id, 'restart')}
</tr> 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"
</tbody> >
</table> <RotateCw className={`w-3.5 h-3.5 ${actionLoading === c.Id + 'restart' ? 'animate-spin' : ''}`} />
</div> </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> </Card>
)} )}
</div> </div>
)} )}
</main>
</div> </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 && selected && (
<LogsModal <LogsModal
hostId={selected.id} hostId={selected.id}
@@ -283,32 +362,27 @@ function LogsModal({ hostId, containerId, name, onClose }: {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
return ( 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="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<div <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()}>
className="bg-surface border border-border rounded-2xl w-full max-w-4xl max-h-[80vh] flex flex-col shadow-2xl" <div className="flex items-center justify-between px-6 py-4 border-b border-border/80">
onClick={e => e.stopPropagation()} <div className="flex items-center gap-2.5">
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<ScrollText className="w-4 h-4 text-accent" /> <ScrollText className="w-4 h-4 text-accent" />
<span className="font-semibold text-sm">{name}</span> <span className="font-bold text-sm text-white tracking-tight">{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="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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={load} disabled={loading} className="h-7 text-xs"> <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 h-3 mr-1 ${loading ? 'animate-spin' : ''}`} /> Yenile <RotateCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} /> Yenile
</Button> </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" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-auto p-4 bg-black/40 rounded-b-2xl"> <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">
<pre className="text-[11px] font-mono text-text/80 whitespace-pre-wrap leading-relaxed"> {logs}
{logs}
</pre>
</div> </div>
</div> </Card>
</div> </div>
) )
} }
@@ -330,27 +404,35 @@ function AddHostModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =
} }
return ( return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}> <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-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}> <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/50"> <CardHeader className="pb-4 border-b border-border/80">
<CardTitle>Yeni Docker Host</CardTitle> <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> </CardHeader>
<CardContent className="pt-6 space-y-4"> <CardContent className="pt-6 space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Sunucu Adı</label> <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>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Docker API URL</label> <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 }))} /> <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-[10px] text-muted font-mono mt-1">Docker Daemon'ın TCP üzerinden erişilebilir olduğundan emin olun.</p> <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> </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"> <div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button> <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"> <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'} {loading ? 'Ekleniyor...' : 'Ekle'}
</Button> </Button>
</div> </div>
+272 -241
View File
@@ -1,8 +1,9 @@
'use client' 'use client'
import React, { useState } from 'react' 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 { cn } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
interface AuditItem { interface AuditItem {
id: string id: string
@@ -43,7 +44,6 @@ export default function InsightPage() {
const result = await res.json() const result = await res.json()
if (!res.ok) throw new Error(result.error || 'Analiz başarısız') if (!res.ok) throw new Error(result.error || 'Analiz başarısız')
setData(result) setData(result)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
@@ -53,21 +53,21 @@ export default function InsightPage() {
} }
const getScoreColor = (score: number) => { const getScoreColor = (score: number) => {
if (score >= 0.9) return 'text-green-500 border-green-500 bg-green-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-orange-500 border-orange-500 bg-orange-500/10' if (score >= 0.5) return 'text-[#ffab00] border-[#ffab00] bg-[#ffab00]/10 shadow-[0_0_12px_rgba(255,171,0,0.1)]'
return 'text-red-500 border-red-500 bg-red-500/10' return 'text-[#ff3d71] border-[#ff3d71] bg-[#ff3d71]/10 shadow-[0_0_12px_rgba(255,61,113,0.1)]'
} }
const getScoreTextColor = (score: number) => { const getScoreTextColor = (score: number) => {
if (score >= 0.9) return 'text-green-500' if (score >= 0.9) return 'text-[#00e676]'
if (score >= 0.5) return 'text-orange-500' if (score >= 0.5) return 'text-[#ffab00]'
return 'text-red-500' return 'text-[#ff3d71]'
} }
const getScoreBgColor = (score: number) => { const getScoreBgColor = (score: number) => {
if (score >= 0.9) return 'bg-green-500' if (score >= 0.9) return 'bg-[#00e676]'
if (score >= 0.5) return 'bg-orange-500' if (score >= 0.5) return 'bg-[#ffab00]'
return 'bg-red-500' return 'bg-[#ff3d71]'
} }
const formatMetric = (value: number, unit: string = 's') => { const formatMetric = (value: number, unit: string = 's') => {
@@ -80,257 +80,288 @@ export default function InsightPage() {
const diagnostics: AuditItem[] = data?.lighthouseResult?.diagnostics ?? [] const diagnostics: AuditItem[] = data?.lighthouseResult?.diagnostics ?? []
return ( return (
<div className="p-6 max-w-6xl mx-auto"> <div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
<div className="mb-8"> {/* Insightus Top Header Bar */}
<h1 className="text-2xl font-bold text-text flex items-center gap-2"> <header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<Gauge className="w-6 h-6 text-accent" /> <div className="flex items-center gap-2.5">
Page Insights <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)]">
</h1> <Gauge className="w-full h-full text-accent" />
<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> <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"> {/* Main Content Area */}
<form onSubmit={handleAnalyze} className="flex flex-col sm:flex-row gap-4"> <div className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#070a13]">
<div className="relative flex-1"> <div className="max-w-5xl mx-auto space-y-6 fade-up">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-muted" /> {/* Subtitle description */}
</div> <div>
<input <p className="text-xs text-muted/80 font-mono bg-[#0b0f19] border border-border/80 w-fit px-3 py-1 rounded-md">
type="text" Lighthouse motoru ile web sayfalarınızın performansını, erişilebilirliğini ve SEO'sunu ölçün.
value={url} </p>
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
/>
</div> </div>
<div className="flex bg-background border border-border rounded-lg p-1"> {/* Search URL form container */}
<button <Card className="bg-[#0b0f19] border-border/80 shadow-md">
type="button" <CardContent className="pt-6">
onClick={() => setStrategy('mobile')} <form onSubmit={handleAnalyze} className="flex flex-col md:flex-row gap-4">
className={cn( <div className="relative flex-1">
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors", <div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
strategy === 'mobile' ? "bg-surface text-text shadow-sm" : "text-muted hover:text-text" <Search className="h-4 w-4 text-muted/70" />
)}
>
<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>
</div> </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>
)
})}
</div>
{/* Core Web Vitals */} <div className="flex bg-black/20 border border-[#1d2639] rounded-xl p-1 shrink-0">
<div className="bg-surface border border-border rounded-xl overflow-hidden"> <button
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2"> type="button"
<Activity className="w-5 h-5 text-accent" /> onClick={() => setStrategy('mobile')}
<h2 className="font-semibold text-text">Core Web Vitals & Metrikler</h2> className={cn(
</div> "flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-xs font-bold transition-all",
<div className="p-6"> strategy === 'mobile' ? "bg-[#141b2a] text-white border border-accent/20 shadow-sm" : "text-muted hover:text-white"
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> )}
>
<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: 'performance', label: 'Performans' },
{ id: 'largest-contentful-paint', label: 'Largest Contentful Paint', desc: 'En büyük içeriğin görünme süresi', unit: 's' }, { id: 'accessibility', label: 'Erişilebilirlik' },
{ id: 'total-blocking-time', label: 'Total Blocking Time', desc: 'Etkileşimi engelleyen toplam süre', unit: 'ms' }, { id: 'best-practices', label: 'En İyi Pratikler' },
{ id: 'cumulative-layout-shift', label: 'Cumulative Layout Shift', desc: 'Beklenmeyen düzen kayması miktarı', unit: 'unitless' }, { id: 'seo', label: 'SEO' },
{ id: 'speed-index', label: 'Speed Index', desc: 'İçeriklerin görsel olarak dolma hızı', unit: 's' }, ].map((cat) => {
{ id: 'interactive', label: 'Time to Interactive', desc: 'Sayfanın tamamen etkileşimli olma süresi', unit: 's' }, const score = data.lighthouseResult.categories[cat.id]?.score ?? 0
].map((metric) => {
const audit = data.lighthouseResult.audits[metric.id]
if (!audit) return null
return ( return (
<div key={metric.id} className="p-4 rounded-lg bg-background border border-border"> <Card key={cat.id} className="bg-[#0b0f19] border-border/80 shadow flex flex-col items-center justify-center text-center p-6">
<div className="flex items-start justify-between mb-2"> <div className={cn(
<div> "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",
<h4 className="font-medium text-text">{metric.label}</h4> getScoreColor(score)
<p className="text-xs text-muted mt-1">{metric.desc}</p> )}>
</div> {Math.round(score * 100)}
<div className={cn("text-lg font-bold shrink-0 ml-2", getScoreTextColor(audit.score))}>
{formatMetric(audit.numericValue, metric.unit)}
</div>
</div> </div>
<div className="w-full bg-surface-2 rounded-full h-1.5 mt-3"> <h3 className="text-xs font-bold text-white tracking-tight">{cat.label}</h3>
<div </Card>
className={cn("h-1.5 rounded-full", getScoreBgColor(audit.score))}
style={{ width: `${Math.max(5, audit.score * 100)}%` }}
/>
</div>
</div>
) )
})} })}
</div> </div>
</div>
</div>
{/* Fırsatlar */} {/* Web Vitals speed audit metrics cards */}
{opportunities.length > 0 && ( <Card className="bg-[#0b0f19] border-border/80 shadow">
<div className="bg-surface border border-border rounded-xl overflow-hidden"> <CardHeader className="pb-3 border-b border-border/30 flex flex-row items-center gap-2">
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2"> <Activity className="w-4 h-4 text-accent" />
<Zap className="w-5 h-5 text-orange-500" /> <CardTitle className="text-xs text-white font-bold tracking-tight">Core Web Vitals & Hız Metrikleri</CardTitle>
<h2 className="font-semibold text-text">Fırsatlar</h2> </CardHeader>
<span className="text-xs text-muted ml-auto">Sayfa yüklenme süresini kısaltabilecek öneriler</span> <CardContent className="pt-6">
</div> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="divide-y divide-border"> {[
{opportunities.map((audit) => ( { id: 'first-contentful-paint', label: 'First Contentful Paint', desc: 'İlk metin veya görselin görünme süresi', unit: 's' },
<div key={audit.id} className="px-6 py-4 flex items-start gap-4"> { id: 'largest-contentful-paint', label: 'Largest Contentful Paint', desc: 'En büyük içeriğin görünme süresi', unit: 's' },
<div className="mt-0.5 shrink-0"> { id: 'total-blocking-time', label: 'Total Blocking Time', desc: 'Etkileşimi engelleyen toplam süre', unit: 'ms' },
{audit.score !== null && audit.score < 0.5 { id: 'cumulative-layout-shift', label: 'Cumulative Layout Shift', desc: 'Beklenmeyen düzen kayması miktarı', unit: 'unitless' },
? <XCircle className="w-4 h-4 text-red-500" /> { id: 'speed-index', label: 'Speed Index', desc: 'İçeriklerin görsel olarak dolma hızı', unit: 's' },
: <AlertTriangle className="w-4 h-4 text-orange-500" /> { id: 'interactive', label: 'Time to Interactive', desc: 'Sayfanın tamamen etkileşimli olma süresi', unit: 's' },
} ].map((metric) => {
</div> const audit = data.lighthouseResult.audits[metric.id]
<div className="flex-1 min-w-0"> if (!audit) return null
<p className="text-sm font-medium text-text">{audit.title}</p>
{audit.description && ( return (
<p className="text-xs text-muted mt-0.5 line-clamp-1"> <div key={metric.id} className="p-4 rounded-xl bg-black/15 border border-[#1d2639] flex flex-col justify-between">
{stripMarkdown(audit.description)} <div className="flex items-start justify-between mb-2">
</p> <div>
)} <h4 className="text-xs font-bold text-white tracking-tight">{metric.label}</h4>
</div> <p className="text-[10px] text-muted mt-1 leading-normal">{metric.desc}</p>
{audit.displayValue && ( </div>
<span className={cn( <div className={cn("text-sm font-bold shrink-0 ml-2 font-mono", getScoreTextColor(audit.score))}>
"text-sm font-mono shrink-0", {formatMetric(audit.numericValue, metric.unit)}
audit.score !== null && audit.score < 0.5 ? 'text-red-500' : 'text-orange-500' </div>
)}> </div>
{audit.displayValue} <div className="w-full bg-black/35 rounded-full h-1 mt-3">
</span> <div
)} className={cn("h-1 rounded-full", getScoreBgColor(audit.score))}
style={{ width: `${Math.max(5, audit.score * 100)}%` }}
/>
</div>
</div>
)
})}
</div> </div>
))} </CardContent>
</div> </Card>
</div>
)}
{/* Teşhisler */} {/* Opportunities warnings */}
{diagnostics.length > 0 && ( {opportunities.length > 0 && (
<div className="bg-surface border border-border rounded-xl overflow-hidden"> <Card className="bg-[#0b0f19] border-border/80 shadow overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2"> <CardHeader className="pb-3 border-b border-border/30 bg-black/5 flex flex-row items-center gap-2">
<Info className="w-5 h-5 text-accent" /> <Zap className="w-4 h-4 text-[#ffab00]" />
<h2 className="font-semibold text-text">Teşhisler</h2> <CardTitle className="text-xs text-white font-bold tracking-tight">Fırsatlar</CardTitle>
<span className="text-xs text-muted ml-auto">Performansı etkileyen ek bilgiler</span> <span className="text-[10px] text-muted ml-auto font-mono">Sayfa yüklenme süresini kısaltabilecek öneriler</span>
</div> </CardHeader>
<div className="divide-y divide-border"> <div className="divide-y divide-border/40 bg-black/5">
{diagnostics.map((audit) => { {opportunities.map((audit) => (
const passed = audit.score === null || audit.score >= 0.9 <div key={audit.id} className="px-6 py-4 flex items-start gap-4 hover:bg-white/[0.005] transition-all">
return ( <div className="mt-0.5 shrink-0">
<div key={audit.id} className="px-6 py-4 flex items-start gap-4"> {audit.score !== null && audit.score < 0.5 ? (
<div className="mt-0.5 shrink-0"> <XCircle className="w-4 h-4 text-[#ff3d71]" />
{audit.score === null ) : (
? <Info className="w-4 h-4 text-muted" /> <AlertTriangle className="w-4 h-4 text-[#ffab00]" />
: passed )}
? <CheckCircle2 className="w-4 h-4 text-green-500" /> </div>
: audit.score < 0.5 <div className="flex-1 min-w-0">
? <XCircle className="w-4 h-4 text-red-500" /> <p className="text-xs font-bold text-white tracking-tight">{audit.title}</p>
: <AlertTriangle className="w-4 h-4 text-orange-500" /> {audit.description && (
} <p className="text-[11px] text-muted mt-1 leading-normal">
</div> {stripMarkdown(audit.description)}
<div className="flex-1 min-w-0"> </p>
<p className="text-sm font-medium text-text">{audit.title}</p> )}
{audit.description && ( </div>
<p className="text-xs text-muted mt-0.5 line-clamp-1"> {audit.displayValue && (
{stripMarkdown(audit.description)} <span className={cn(
</p> "text-xs font-mono font-bold shrink-0",
audit.score !== null && audit.score < 0.5 ? 'text-[#ff3d71]' : 'text-[#ffab00]'
)}>
{audit.displayValue}
</span>
)} )}
</div> </div>
{audit.displayValue && ( ))}
<span className={cn( </div>
"text-sm font-mono shrink-0", </Card>
audit.score === null ? 'text-muted' : passed ? 'text-green-500' : audit.score < 0.5 ? 'text-red-500' : 'text-orange-500' )}
)}>
{audit.displayValue} {/* Diagnostics notes */}
</span> {diagnostics.length > 0 && (
)} <Card className="bg-[#0b0f19] border-border/80 shadow overflow-hidden">
</div> <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>
</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>
</div> </div>
) )
} }
+182 -77
View File
@@ -1,5 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { LayoutDashboard, Globe, Database, Server, Clock, ArrowRight, Activity, Loader2 } from 'lucide-react'
type Config = { type Config = {
sites: { id: string; name: string; url: string }[] 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() { export default function OverviewPage() {
const [config, setConfig] = useState<Config | null>(null) const [config, setConfig] = useState<Config | null>(null)
const [statuses, setStatuses] = useState<Record<string, SiteStatus>>({}) const [statuses, setStatuses] = useState<Record<string, SiteStatus>>({})
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch('/api/config').then(r => r.json()).then(async (cfg: Config) => { setLoading(true)
setConfig(cfg) fetch('/api/config')
// Her site için son durumu çek .then(r => r.json())
const results = await Promise.all( .then(async (cfg: Config) => {
cfg.sites.map(s => setConfig(cfg)
fetch(`/api/ping?siteId=${s.id}`).then(r => r.json()).then(d => ({ // Fetch last statuses for each site
id: s.id, status: d.last?.status ?? null, ms: d.last?.ms ?? null, uptime24: d.uptime24 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> = {}
const map: Record<string, SiteStatus> = {} results.forEach(r => { map[r.id] = r })
results.forEach(r => { map[r.id] = r }) setStatuses(map)
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 ( return (
<div style={{ padding: 28 }}> <div className="flex flex-col min-h-screen bg-[#070a13] text-[#e2e8f0]">
<div style={{ marginBottom: 28 }}> {/* Top Header Bar */}
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Overview</h1> <header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}> <div className="flex items-center gap-2.5">
{config.sites.length} site · {config.databases.length} DB · {config.services.length} servis <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)]">
</p> <LayoutDashboard className="w-full h-full text-accent" />
</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> </div>
))} <span className="font-extrabold text-base tracking-tight text-white select-none">Overview</span>
</div> </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 */} {/* Overview Content */}
{config.sites.length > 0 && ( <div className="flex-1 overflow-y-auto p-8 custom-scrollbar max-w-5xl w-full mx-auto space-y-8 fade-up">
<div style={{ marginBottom: 24 }}> {/* Description Section */}
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10 }}>Site Durumu</div> <div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}> <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.map((site, i) => { {config.sites.length} site · {config.databases.length} DB · {config.services.length} servis
const st = statuses[site.id] </p>
return ( </div>
<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 }}> {/* Statistical Metrics cards */}
<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 className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div> {[
<div style={{ fontSize: 13, fontWeight: 600 }}>{site.name}</div> {
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{site.url}</div> label: 'Sites Online',
</div> value: `${config.sites.filter(s => statuses[s.id]?.status === 'up').length}/${config.sites.length}`,
</div> sub: 'online',
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}> color: 'text-[#00e676]',
{st?.ms && <span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)' }}>{st.ms}ms</span>} icon: Globe,
{st?.uptime24 !== undefined && <span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--green)' }}>{st.uptime24}%</span>} glowColor: 'rgba(0,230,118,0.1)'
<span style={{ fontSize: 11, fontFamily: 'monospace', fontWeight: 700, color: statusColor(st?.status ?? null) }}> },
{st?.status?.toUpperCase() ?? '—'} {
</span> label: 'Databases',
</div> 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>
) ) : (
})} <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>
</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>
</div> </div>
) )
+163 -75
View File
@@ -1,5 +1,9 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' 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 } 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 load = useCallback(async () => {
const r = await fetch('/api/config') const r = await fetch('/api/config')
const cfg = await r.json() const cfg = await r.json()
setServices(cfg.services) setServices(cfg.services || [])
}, []) }, [])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
const del = async (id: string) => { 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 }) }) await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'service', id }) })
load() load()
} }
return ( return (
<div style={{ padding: 28 }}> <div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}> {/* Servicus Top Header Bar */}
<div> <header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Servisler</h1> <div className="flex items-center gap-2.5">
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Coolify, Grafana, n8n vs. hızlı erişim linkleri</p> <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> </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> </div>
{services.length === 0 {/* Add Website Modal */}
? ( {showAdd && (
<div style={{ background: 'var(--surface)', border: '1px dashed var(--border)', borderRadius: 12, padding: '40px', textAlign: 'center', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}> <ServiceModal
Henüz servis eklenmedi.<br /> onClose={() => setShowAdd(false)}
<span style={{ color: 'var(--accent)', cursor: 'pointer' }} onClick={() => setShowAdd(true)}>+ Ekle</span> ile Coolify, Grafana gibi araçlara hızlı erişim ekle. onAdded={() => { load(); setShowAdd(false) }}
</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>
)
}
{showAdd && <ServiceModal onClose={() => setShowAdd(false)} onAdded={() => { load(); setShowAdd(false) }} />} {/* Edit Website Modal */}
{editingService && ( {editingService && (
<ServiceModal <ServiceModal
service={editingService} service={editingService}
@@ -95,9 +163,6 @@ function ServiceModal({ service, onClose, onAdded }: { service?: Service; onClos
onAdded() 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 = [ const presets = [
{ name: 'Coolify', url: 'http://localhost:8000', icon: '⚙️', description: 'Deploy yönetimi' }, { name: 'Coolify', url: 'http://localhost:8000', icon: '⚙️', description: 'Deploy yönetimi' },
{ name: 'Grafana', url: 'http://localhost:3000', icon: '📊', description: 'Metrik görselleştirme' }, { 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 ( return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}> <div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 420 }} onClick={e => e.stopPropagation()}> <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()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>{service ? 'Servis Düzenle' : 'Servis Ekle'}</div> <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 && ( {/* Form fields */}
<div style={{ marginBottom: 16 }}> <div className="grid grid-cols-[80px_1fr] gap-3">
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Hızlı Ekle</div> <div className="space-y-1.5">
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">İkon</label>
{presets.map(p => ( <Input value={f.icon} onChange={e => setF(p => ({ ...p, icon: e.target.value }))} className="bg-black/20 border-[#1d2639] text-center text-lg" />
<button key={p.name} onClick={() => setF(p)} </div>
style={{ background: 'rgba(255,255,255,.04)', border: '1px solid var(--border)', borderRadius: 6, padding: '5px 10px', color: 'var(--text)', fontSize: 12, cursor: 'pointer' }}> <div className="space-y-1.5">
{p.icon} {p.name} <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Ad</label>
</button> <Input placeholder="Coolify" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
))}
</div> </div>
</div> </div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div className="space-y-1.5">
<div style={{ display: 'grid', gridTemplateColumns: '50px 1fr', gap: 8 }}> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">URL</label>
<div><label style={lbl}>İkon</label><input style={inp} value={f.icon} onChange={e => setF(p => ({ ...p, icon: e.target.value }))} /></div> <Input value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} className="bg-black/20 border-[#1d2639] font-mono text-sm" />
<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> </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 }}> <div className="space-y-1.5">
<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> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Açıklama</label>
<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 }}> <Input placeholder="Deploy yönetimi" value={f.description} onChange={e => setF(p => ({ ...p, description: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
{loading ? '...' : service ? 'Güncelle' : 'Ekle'} </div>
</button>
</div> <div className="flex gap-3 pt-4 border-t border-border/80 mt-6">
</div> <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> </div>
) )
} }
+336 -147
View File
@@ -1,14 +1,18 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' 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 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 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 SiteData = { logs: Log[]; uptime24: number; uptime7d: number; last: Log | null }
type NotifSettings = { webhook_url: string; telegram_token: string; telegram_chat_id: string; enabled: boolean } 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() { export default function UptimePage() {
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [selected, setSelected] = useState<Site | null>(null) const [selected, setSelected] = useState<Site | null>(null)
@@ -21,13 +25,14 @@ export default function UptimePage() {
const loadSites = useCallback(async () => { const loadSites = useCallback(async () => {
const r = await fetch('/api/config') const r = await fetch('/api/config')
const cfg = await r.json() const cfg = await r.json()
setSites(cfg.sites) setSites(cfg.sites || [])
}, []) }, [])
useEffect(() => { loadSites() }, [loadSites]) useEffect(() => { loadSites() }, [loadSites])
const selectSite = async (site: Site) => { const selectSite = async (site: Site) => {
setSelected(site); setData(null) setSelected(site)
setData(null)
const r = await fetch(`/api/ping?siteId=${site.id}`) const r = await fetch(`/api/ping?siteId=${site.id}`)
setData(await r.json()) setData(await r.json())
} }
@@ -49,119 +54,274 @@ export default function UptimePage() {
loadSites() 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() const last50 = (data?.logs ?? []).slice(0, 50).reverse()
return ( return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}> <div className="flex flex-col h-screen overflow-hidden bg-[#070a13] text-[#e2e8f0]">
{/* LEFT */} {/* Uptimus Top Header Bar */}
<div style={{ width: 260, background: 'var(--surface)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}> <header className="h-14 bg-[#0a0d16] border-b border-border/80 flex items-center justify-between px-6 shrink-0 z-10">
<div style={{ padding: '16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="flex items-center gap-2.5">
<span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>Uptime</span> <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)]">
<div style={{ display: 'flex', gap: 6 }}> <Activity className="w-full h-full text-accent animate-pulse" />
<button onClick={() => setShowNotif(true)} title="Bildirim Ayarları" style={{ background: 'transparent', border: '1px solid var(--border)', borderRadius: 6, padding: '4px 8px', color: 'var(--muted)', fontSize: 12, cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Bell size={13} />
</button>
<button onClick={() => setShowAdd(true)} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '4px 10px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer' }}>+ Ekle</button>
</div> </div>
<span className="font-extrabold text-base tracking-tight text-white select-none">Uptimus</span>
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}> <div className="flex items-center gap-3">
{sites.length === 0 <Button
? <div style={{ padding: 20, color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace', textAlign: 'center' }}>Henüz site yok</div> variant="outline"
: sites.map(site => ( size="sm"
<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'}` }}> onClick={() => setShowNotif(true)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}> className="h-8 border-[#1d2639] hover:bg-[#1a1f2e] text-[#e2e8f0] px-3 gap-1.5"
<span style={{ fontSize: 13, fontWeight: 600 }}>{site.name}</span> title="Bildirim Ayarları"
<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> <Bell size={13} />
<button onClick={e => { e.stopPropagation(); deleteSite(site.id) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer' }}></button> <span>Alerts</span>
</div> </Button>
</div> <span className="text-xs font-semibold font-mono text-muted uppercase tracking-widest hover:text-accent transition-colors duration-200 cursor-pointer">
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site.url}</div> Community
</div> </span>
))}
</div> </div>
</div> </header>
{/* RIGHT */} {/* Content Layout */}
<div style={{ flex: 1, overflow: 'auto' }}> <div className="flex flex-1 overflow-hidden">
{!selected {/* Left Panel: Site Cards list */}
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}> Site seç</div> <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">
<div style={{ padding: 24 }}> {sites.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}> <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>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}> <div className="flex items-center gap-2.5 mb-2">
<div style={{ width: 9, height: 9, borderRadius: '50%', background: sc(data?.last?.status ?? null), boxShadow: `0 0 8px ${sc(data?.last?.status ?? null)}` }} /> <span className="relative flex h-3 w-3 shrink-0">
<h2 style={{ fontSize: 18, fontWeight: 800 }}>{selected.name}</h2> {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>
<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> </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> </div>
{/* Stats */} {/* Stats metric cards row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12, marginBottom: 24 }}> <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) }, { label: 'Current Status', value: data?.last?.status?.toUpperCase() ?? '—', c: sc(data?.last?.status ?? null), icon: Activity },
{ l: 'Response', v: data?.last?.ms ? `${data.last.ms}ms` : '—', c: 'var(--text)' }, { label: 'Latency Response', value: data?.last?.ms ? `${data.last.ms}ms` : '—', c: 'text-white', icon: Zap },
{ l: 'Uptime 24s', v: data ? `${data.uptime24}%` : '—', c: 'var(--green)' }, { label: 'Uptime 24h', value: data ? `${data.uptime24}%` : '—', c: 'text-[#00e676]', icon: CheckCircle2 },
{ l: 'Uptime 7g', v: data ? `${data.uptime7d}%` : '—', c: 'var(--green)' }, { label: 'Uptime 7d', value: data ? `${data.uptime7d}%` : '—', c: 'text-[#00e676]', icon: CheckCircle2 },
].map(s => ( ].map(s => (
<div key={s.l} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}> <div key={s.label} className="bg-[#0b0f19] border border-[#1d2639] rounded-xl p-5 shadow">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }}>{s.l}</div> <div className="flex justify-between items-start mb-3">
<div style={{ fontSize: 20, fontWeight: 800, color: s.c }}>{s.v}</div> <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>
))} ))}
</div> </div>
{/* Status bar */} {/* Status checks timeline bar */}
{last50.length > 0 && ( {last50.length > 0 && (
<div style={{ marginBottom: 24 }}> <div className="space-y-4">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Son {last50.length} kontrol</div> <h3 className="text-xs text-muted font-mono uppercase tracking-widest">Recent Checks Timeline</h3>
<div style={{ display: 'flex', gap: 2, height: 28 }}> <div className="bg-[#0b0f19] border border-border/80 rounded-xl p-5">
{last50.map((log, i) => ( <div className="flex gap-1.5 h-7">
<div key={i} title={`${log.status}${log.ms}ms • ${new Date(log.ts * 1000).toLocaleString('tr-TR')}`} {last50.map((log, i) => (
style={{ flex: 1, borderRadius: 2, background: sc(log.status), opacity: .5 + (i / last50.length) * .5, cursor: 'help' }} /> <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>
</div> </div>
)} )}
{/* Log table */} {/* Ping check Log table */}
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Log</div> <div className="space-y-4">
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}> <h3 className="text-xs text-muted font-mono uppercase tracking-widest">Check History Logs</h3>
{!data ? ( <div className="bg-[#0b0f19] border border-border/80 rounded-xl overflow-hidden shadow-xl">
<div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Yükleniyor...</div> {!data ? (
) : data.logs.length === 0 ? ( <div className="p-8 text-center text-muted font-mono text-xs animate-pulse">
<div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Henüz log yok Ping At butonuna bas</div> Loading latency logs...
) : ( </div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}> ) : data.logs.length === 0 ? (
<thead> <div className="p-8 text-center text-muted font-mono text-xs">
<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> No logs recorded yet. Click "Run check now" to ping.
</thead> </div>
<tbody> ) : (
{data.logs.slice(0, 50).map((log, i) => ( <div className="overflow-x-auto">
<tr key={i}> <table className="w-full text-left border-collapse">
<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> <thead>
<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> <tr className="bg-[#0e1422]">
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.ms ?? '—'}</td> {['Timestamp', 'Status', 'Response', 'HTTP Code', 'Error details'].map(h => (
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.code ?? '—'}</td> <th key={h} className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-muted/80 border-b border-border/80">
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', color: 'var(--red)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.error ?? '—'}</td> {h}
</tr> </th>
))} ))}
</tbody> </tr>
</table> </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>
</div> </div>
)} )}
</main>
</div> </div>
{showAdd && <AddSiteModal onClose={() => setShowAdd(false)} onAdded={() => { loadSites(); setShowAdd(false) }} />} {/* Add Website Modal */}
{editingSite && <AddSiteModal site={editingSite} onClose={() => setEditingSite(null)} onAdded={() => { loadSites(); setEditingSite(null); if (selected?.id === editingSite.id) selectSite(editingSite) }} />} {showAdd && (
{showNotif && <NotifModal onClose={() => setShowNotif(false)} />} <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> </div>
) )
} }
@@ -186,25 +346,35 @@ function AddSiteModal({ site, onClose, onAdded }: { site?: Site; onClose: () =>
setLoading(false) 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 ( return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}> <div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 400 }} onClick={e => e.stopPropagation()}> <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()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 20 }}>{site ? 'Site Düzenle' : 'Yeni Site Ekle'}</div> <CardHeader className="pb-4 border-b border-border/80">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <CardTitle className="text-white text-lg font-bold">{site ? 'Site Düzenle' : 'Yeni Site Ekle'}</CardTitle>
<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> <CardDescription className="text-muted/80">İzlemek istediğiniz web sitesinin detaylarını girin.</CardDescription>
<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> </CardHeader>
<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> <CardContent className="pt-6 space-y-4">
</div> <div className="space-y-1.5">
<div style={{ display: 'flex', gap: 10, marginTop: 24 }}> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">İsim</label>
<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> <Input placeholder="Örn: Portföy Sitem" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} className="bg-black/20 border-[#1d2639]" />
<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 }}> </div>
{loading ? 'Kaydediliyor...' : site ? 'Kaydet' : 'Ekle & Ping At'} <div className="space-y-1.5">
</button> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">URL</label>
</div> <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>
<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> </div>
) )
} }
@@ -230,48 +400,67 @@ function NotifModal({ onClose }: { onClose: () => void }) {
setTimeout(() => setSaved(false), 2000) 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 ( return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}> <div className="fixed inset-0 bg-black/75 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 460 }} onClick={e => e.stopPropagation()}> <Card className="w-full max-w-[480px] shadow-2xl bg-[#131926] border-[#1d2639] text-[#e2e8f0]" onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}> <CardHeader className="pb-4 border-b border-border/80 flex flex-row items-center justify-between">
<div style={{ fontSize: 15, fontWeight: 700 }}>Bildirim Ayarları</div> <div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}> <CardTitle className="text-white text-lg font-bold">Bildirim Ayarları</CardTitle>
<input type="checkbox" checked={f.enabled} onChange={e => setF(p => ({ ...p, enabled: e.target.checked }))} /> <CardDescription className="text-muted/80">Downtime bildirimleri için hedefler tanımlayın.</CardDescription>
Etkin
</label>
</div>
{loading ? <div style={{ color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Yükleniyor...</div> : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10, color: 'var(--accent)' }}>Webhook</div>
<label style={lbl}>URL (POST JSON)</label>
<input style={inp} placeholder="https://hooks.slack.com/... veya discord webhook" value={f.webhook_url} onChange={e => setF(p => ({ ...p, webhook_url: e.target.value }))} />
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10, color: 'var(--accent)' }}>Telegram</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div><label style={lbl}>Bot Token</label><input style={inp} placeholder="123456:ABC-DEF..." value={f.telegram_token} onChange={e => setF(p => ({ ...p, telegram_token: e.target.value }))} /></div>
<div><label style={lbl}>Chat ID</label><input style={inp} placeholder="-100xxxxxxxxx veya @channel" value={f.telegram_chat_id} onChange={e => setF(p => ({ ...p, telegram_chat_id: e.target.value }))} /></div>
</div>
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', lineHeight: 1.6 }}>
@BotFather'dan bot oluştur → token al → botu kanala ekle → /getUpdates ile chat_id bul
</div>
</div>
</div> </div>
)} <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 }}> <div className="border-t border-border/50 pt-4">
<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> <div className="text-xs font-bold text-accent mb-2">Telegram Bot Bildirimleri</div>
<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 }}> <div className="space-y-3">
{saving ? 'Kaydediliyor...' : saved ? ' Kaydedildi' : 'Kaydet'} <div className="space-y-1.5">
</button> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bot Token</label>
</div> <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>
<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> </div>
) )
} }
+17 -7
View File
@@ -36,8 +36,10 @@ pool.query(`
CREATE TABLE IF NOT EXISTS analytics_sites ( CREATE TABLE IF NOT EXISTS analytics_sites (
domain TEXT PRIMARY KEY, domain TEXT PRIMARY KEY,
name TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT) 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 ( CREATE TABLE IF NOT EXISTS config_sites (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -96,6 +98,7 @@ pool.query(`
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT) ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
); );
CREATE INDEX IF NOT EXISTS idx_backup_db_ts ON backup_logs(db_id, ts DESC); CREATE 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 ( CREATE TABLE IF NOT EXISTS notification_settings (
id TEXT PRIMARY KEY DEFAULT 'default', 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 } 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(` const res = await pool.query(`
SELECT domain FROM analytics_sites SELECT domain, name FROM analytics_sites
UNION 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 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( await pool.query(
`INSERT INTO analytics_sites (domain) VALUES ($1) ON CONFLICT DO NOTHING`, `INSERT INTO analytics_sites (domain, name) VALUES ($1, $2) ON CONFLICT (domain) DO UPDATE SET name = EXCLUDED.name`,
[domain] [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]) 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) { export async function deleteAnalyticsSite(domain: string) {
await pool.query(`DELETE FROM analytics_sites WHERE domain = $1`, [domain]) await pool.query(`DELETE FROM analytics_sites WHERE domain = $1`, [domain])
await pool.query(`DELETE FROM pageviews 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 : row.cloud_type
console.log(`[Backup] ${db.name} (${effectiveType}) başlatılıyor...`) console.log(`[Backup] ${db.name} (${effectiveType}) başlatılıyor...`)
const backupStart = Date.now()
try { try {
const buffer = await createDbDumpBuffer(db) const buffer = await createDbDumpBuffer(db)
const filename = effectiveType === 'gdrive' ? backupFilename() : backupFilename(db.name) const filename = effectiveType === 'gdrive' ? backupFilename() : backupFilename(db.name)
await runUpload(row, db, buffer, filename) await runUpload(row, db, buffer, filename)
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query( await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, `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] [db.id, 'success', 'Otomatik yedek', buffer.length, durationSec]
) )
console.log(`[Backup] ${db.name} tamamlandı.`) console.log(`[Backup] ${db.name} tamamlandı.`)
} catch (e: any) { } catch (e: any) {
console.error(`[Backup] Hata (${db.name}):`, e) console.error(`[Backup] Hata (${db.name}):`, e)
const durationSec = Math.round((Date.now() - backupStart) / 1000)
await pool.query( await pool.query(
`INSERT INTO backup_logs (db_id, status, message, file_size) VALUES ($1, $2, $3, $4)`, `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] [db.id, 'error', e.message || String(e), 0, durationSec]
) )
} }
}) })