feat: add docker monitoring and ui fixes

This commit is contained in:
mstfyldz
2026-06-03 00:58:54 +03:00
parent e5dc347a0b
commit 29d03604f9
24 changed files with 1682 additions and 553 deletions
View File
+191 -1
View File
@@ -10,13 +10,17 @@
"dependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"jose": "^6.2.3",
"lucide-react": "^1.16.0",
"mysql2": "^3.22.4",
"next": "16.2.6",
"node-cron": "^4.2.1",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -2499,6 +2503,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axe-core": {
"version": "4.11.4",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",
@@ -2699,6 +2712,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2877,6 +2899,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3690,6 +3721,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/framer-motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.40.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3731,6 +3789,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -3995,6 +4062,22 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4317,6 +4400,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4924,6 +5013,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4947,6 +5042,21 @@
"yallist": "^3.0.2"
}
},
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/lucide-react": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
@@ -5023,6 +5133,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5030,6 +5155,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.4.tgz",
"integrity": "sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -5866,6 +6025,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6106,6 +6271,21 @@
"node": ">= 10.x"
}
},
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6312,6 +6492,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
+5 -1
View File
@@ -11,13 +11,17 @@
"dependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"jose": "^6.2.3",
"lucide-react": "^1.16.0",
"mysql2": "^3.22.4",
"next": "16.2.6",
"node-cron": "^4.2.1",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+5 -5
View File
@@ -26,7 +26,7 @@ export async function GET(req: Request) {
export async function POST(req: Request) {
try {
const body = await req.json()
const { db_id, schedule, cloud_type, credentials } = body
const { db_id, schedule, cloud_type, credentials, gdrive_folder_id } = body
if (!db_id || !schedule || !cloud_type || !credentials) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
@@ -37,13 +37,13 @@ export async function POST(req: Request) {
if (existing.rows.length > 0) {
await pool.query(
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3 WHERE db_id=$4`,
[schedule, cloud_type, credentials, db_id]
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4 WHERE db_id=$5`,
[schedule, cloud_type, credentials, gdrive_folder_id || null, db_id]
)
} else {
await pool.query(
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials) VALUES ($1, $2, $3, $4, $5)`,
[generateId(), db_id, schedule, cloud_type, credentials]
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id) VALUES ($1, $2, $3, $4, $5, $6)`,
[generateId(), db_id, schedule, cloud_type, credentials, gdrive_folder_id || null]
)
}
+6 -20
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { readConfig } from '@/lib/config'
import { createPgDumpStream } from '@/lib/backup'
import { createDbDumpBuffer } from '@/lib/backup'
import pool from '@/lib/appDb'
// GET /api/db/backup?dbId=...
export async function GET(req: Request) {
@@ -13,35 +14,20 @@ export async function GET(req: Request) {
if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
try {
const child = await createPgDumpStream(db)
const buffer = await createDbDumpBuffer(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql`
// Stream the output of pg_dump to the response
const stream = new ReadableStream({
start(controller) {
child.stdout.on('data', (chunk) => controller.enqueue(chunk))
child.on('close', (code) => {
if (code === 0) {
controller.close()
} else {
controller.error(new Error(`pg_dump exited with code ${code}`))
}
})
child.on('error', (err) => controller.error(err))
},
cancel() {
child.kill()
}
})
await pool.query(`INSERT INTO backup_logs (db_id, status, error, size, target) VALUES ($1, $2, $3, $4, $5)`, [dbId, 'success', null, buffer.length, 'manuel indirme'])
return new NextResponse(stream, {
return new NextResponse(buffer.toString('utf-8'), {
headers: {
'Content-Type': 'application/sql',
'Content-Disposition': `attachment; filename="${filename}"`
}
})
} catch (e: any) {
await pool.query(`INSERT INTO backup_logs (db_id, status, error, size, target) VALUES ($1, $2, $3, $4, $5)`, [dbId, 'error', e.message, 0, 'manuel indirme'])
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { readConfig } from '@/lib/config'
export const dynamic = 'force-dynamic'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const hostId = searchParams.get('hostId')
if (!hostId) {
return NextResponse.json({ error: 'hostId is required' }, { status: 400 })
}
try {
const config = await readConfig()
const host = config.docker_hosts?.find(h => h.id === hostId)
if (!host) {
return NextResponse.json({ error: 'Docker host not found' }, { status: 404 })
}
let baseUrl = host.url
if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1)
// Docker Engine API: list all containers
const res = await fetch(`${baseUrl}/containers/json?all=1`, {
headers: { 'Accept': 'application/json' },
cache: 'no-store'
})
if (!res.ok) {
const text = await res.text()
return NextResponse.json({ error: `Docker API Error: ${res.statusText}`, details: text }, { status: res.status })
}
const containers = await res.json()
return NextResponse.json(containers)
} catch (error: any) {
console.error('Docker API Error:', error)
return NextResponse.json({ error: 'Bağlantı hatası', details: error.message }, { status: 500 })
}
}
+184 -133
View File
@@ -1,5 +1,10 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AnimatedTabs } from '@/components/ui/animated-tabs'
import { Edit2, Trash2, Plus, Code, Globe, CalendarDays } from 'lucide-react'
type Stats = {
total: number
@@ -14,29 +19,29 @@ type Stats = {
}
const DAYS_OPTIONS = [
{ label: '1g', value: 1 },
{ label: '7g', value: 7 },
{ label: '30g', value: 30 },
{ label: '90g', value: 90 },
{ id: '1', label: '1G' },
{ id: '7', label: '7G' },
{ id: '30', label: '30G' },
{ id: '90', label: '90G' },
]
function Bar({ label, value, max, color = 'var(--accent)' }: { label: string; value: number; max: number; color?: string }) {
function Bar({ label, value, max, color = 'var(--color-accent)' }: { label: string; value: number; max: number; color?: string }) {
const pct = max > 0 ? (value / max) * 100 : 0
return (
<div style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: 'var(--text)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, marginRight: 8 }}>{label}</span>
<span style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', flexShrink: 0 }}>{value.toLocaleString()}</span>
<div className="mb-3">
<div className="flex justify-between mb-1.5">
<span className="text-xs text-text font-mono truncate flex-1 mr-2">{label}</span>
<span className="text-xs text-muted font-mono shrink-0">{value.toLocaleString()}</span>
</div>
<div style={{ height: 4, background: 'rgba(255,255,255,.06)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 2, transition: 'width .5s ease' }} />
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-500 ease-out" style={{ width: `${pct}%`, background: color }} />
</div>
</div>
)
}
function MiniChart({ data, days }: { data: { day: string; views: number; visitors: number }[]; days: number }) {
if (!data.length) return <div style={{ height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Henüz veri yok</div>
if (!data.length) return <div className="h-20 flex items-center justify-center text-muted text-xs font-mono">Henüz veri yok</div>
// Tüm günleri doldur
const filled: { day: string; views: number; visitors: number }[] = []
@@ -52,11 +57,19 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
const maxViews = Math.max(...filled.map(d => d.views), 1)
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 80, padding: '0 4px' }}>
<div className="flex items-end gap-1 h-20 px-1">
{filled.map((d, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, height: '100%', justifyContent: 'flex-end' }}
title={`${d.day}: ${d.views} görüntüleme, ${d.visitors} ziyaretçi`}>
<div style={{ width: '100%', background: 'var(--accent)', borderRadius: '2px 2px 0 0', opacity: .85, height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 4 : 0)}%`, transition: 'height .3s ease', minHeight: d.views > 0 ? 2 : 0 }} />
<div
key={i}
className="flex-1 flex flex-col items-center gap-[1px] h-full justify-end"
>
<div
className="w-full bg-accent/80 rounded-t-sm transition-all duration-300 ease-out"
style={{
height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 4 : 0)}%`,
minHeight: d.views > 0 ? 2 : 0
}}
/>
</div>
))}
</div>
@@ -66,7 +79,7 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
export default function AnalyticsPage() {
const [domains, setDomains] = useState<string[]>([])
const [selectedDomain, setSelectedDomain] = useState<string | null>(null)
const [days, setDays] = useState(30)
const [days, setDays] = useState('30')
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(false)
const [showSnippet, setShowSnippet] = useState(false)
@@ -118,7 +131,6 @@ export default function AnalyticsPage() {
})
setSelectedDomain(newName.trim())
// Refresh domains after brief delay for state to sync
setTimeout(loadDomains, 100)
}
@@ -137,193 +149,232 @@ export default function AnalyticsPage() {
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
return (
<div style={{ padding: 28, maxWidth: 1100 }}>
<div className="p-8 max-w-[1200px] mx-auto fade-up">
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-8">
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Analytics</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cookie-free, self-hosted Plausible benzeri</p>
<h1 className="text-2xl font-extrabold tracking-tight mb-1">Analytics</h1>
<p className="text-xs text-muted font-mono tracking-wide">Cookie-free, self-hosted website tracking</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="flex flex-wrap items-center gap-3">
{/* Domain Actions */}
{selectedDomain && (
<>
<button onClick={handleRename} style={{ background: 'transparent', border: '1px solid var(--border)', borderRadius: 7, padding: '8px 14px', color: 'var(--text)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Düzenle</button>
<button onClick={handleDelete} style={{ background: 'rgba(255,50,50,.1)', border: '1px solid rgba(255,50,50,.3)', borderRadius: 7, padding: '8px 14px', color: 'var(--red, #ff5c5c)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Sil</button>
</>
<div className="flex items-center gap-2 bg-surface-2 p-1 border border-border/50 rounded-xl">
<Button variant="ghost" size="sm" onClick={handleRename} className="h-8 px-3 text-xs">
<Edit2 className="w-3.5 h-3.5 mr-1.5" /> Düzenle
</Button>
<div className="w-px h-4 bg-border" />
<Button variant="ghost" size="sm" onClick={handleDelete} className="h-8 px-3 text-xs text-destructive hover:text-destructive hover:bg-destructive/10">
<Trash2 className="w-3.5 h-3.5 mr-1.5" /> Sil
</Button>
</div>
)}
<button onClick={() => setShowAddSite(true)}
style={{ background: 'rgba(0,229,255,.12)', border: '1px solid rgba(0,229,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
+ Site Ekle
</button>
<button onClick={() => setShowSnippet(true)}
style={{ background: 'rgba(123,97,255,.12)', border: '1px solid rgba(123,97,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--purple, #7b61ff)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
{'</>'} Snippet
</button>
{/* Days filter */}
<div style={{ display: 'flex', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 7, overflow: 'hidden' }}>
{DAYS_OPTIONS.map(opt => (
<button key={opt.value} onClick={() => setDays(opt.value)}
style={{ padding: '7px 14px', fontSize: 12, fontFamily: 'monospace', cursor: 'pointer', background: days === opt.value ? 'rgba(0,229,255,.12)' : 'transparent', color: days === opt.value ? 'var(--accent)' : 'var(--muted)', border: 'none', borderRight: '1px solid var(--border)' }}>
{opt.label}
</button>
))}
<Button variant="outline" size="sm" onClick={() => setShowAddSite(true)} className="h-10">
<Plus className="w-4 h-4 mr-1.5" /> Site Ekle
</Button>
<Button variant="default" size="sm" onClick={() => setShowSnippet(true)} className="h-10 bg-purple-500/10 text-purple-400 border-purple-500/30 hover:bg-purple-500/20">
<Code className="w-4 h-4 mr-1.5" /> Snippet
</Button>
{/* Days filter - using AnimatedTabs */}
<div className="ml-2">
<AnimatedTabs
activeTab={days}
onChange={(id) => setDays(id)}
tabs={DAYS_OPTIONS}
/>
</div>
</div>
</div>
{/* Domain tabs */}
{domains.length > 0 ? (
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
<div className="flex flex-wrap gap-2 mb-6 border-b border-border/50 pb-px">
{domains.map(d => (
<button key={d} onClick={() => setSelectedDomain(d)}
style={{ padding: '8px 14px', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'none', border: 'none', borderBottom: `2px solid ${selectedDomain === d ? 'var(--accent)' : 'transparent'}`, color: selectedDomain === d ? 'var(--accent)' : 'var(--muted)', marginBottom: -1 }}>
<button
key={d}
onClick={() => setSelectedDomain(d)}
className={`px-4 py-2 text-sm font-semibold border-b-2 transition-colors flex items-center gap-2 ${
selectedDomain === d
? 'border-accent text-accent'
: 'border-transparent text-muted hover:text-text hover:border-border'
}`}
>
<Globe className="w-3.5 h-3.5" />
{d}
</button>
))}
</div>
) : (
<div style={{ background: 'var(--surface)', border: '1px dashed var(--border)', borderRadius: 12, padding: '40px', textAlign: 'center', marginBottom: 20 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>Henüz veri yok</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 16 }}>Sitelerine tracking snippet ekle, veriler burada görünecek.</div>
<button onClick={() => setShowSnippet(true)}
style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 7, padding: '9px 18px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontFamily: 'monospace' }}>
{'</>'} Snippet&apos;ı Göster
</button>
</div>
<Card className="border-dashed border-2 bg-surface/30">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Globe className="w-12 h-12 text-muted/30 mb-4" />
<h3 className="text-lg font-bold mb-2">Henüz veri yok</h3>
<p className="text-sm text-muted mb-6">Sitelerine tracking snippet ekle, veriler burada görünecek.</p>
<Button onClick={() => setShowSnippet(true)}>
<Code className="w-4 h-4 mr-2" /> Snippet'ı Göster
</Button>
</CardContent>
</Card>
)}
{stats && !loading && (
<>
<div className="space-y-6 fade-up">
{/* Big numbers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 14, marginBottom: 20 }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ label: 'Toplam Görüntüleme', value: stats.total.toLocaleString(), color: 'var(--accent)' },
{ label: 'Tekil Ziyaretçi', value: stats.unique.toLocaleString(), color: 'var(--green)' },
{ label: 'Toplam Görüntüleme', value: stats.total.toLocaleString(), color: 'text-accent' },
{ label: 'Tekil Ziyaretçi', value: stats.unique.toLocaleString(), color: 'text-success' },
].map(s => (
<div key={s.label} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '18px 20px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>{s.label}</div>
<div style={{ fontSize: 32, fontWeight: 800, color: s.color, letterSpacing: -1 }}>{s.value}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>son {days} gün</div>
<Card key={s.label} className="bg-surface/50 border-border/50">
<CardContent className="p-6">
<div className="text-[10px] text-muted font-mono uppercase tracking-widest mb-2">{s.label}</div>
<div className={`text-4xl font-extrabold tracking-tighter ${s.color}`}>{s.value}</div>
<div className="text-xs text-muted font-mono mt-2 flex items-center gap-1">
<CalendarDays className="w-3 h-3" /> son {days} gün
</div>
</CardContent>
</Card>
))}
</div>
{/* Chart */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 20px', marginBottom: 20 }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 12 }}>Günlük Görüntülemeler</div>
<MiniChart data={stats.daily} days={days} />
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6 }}>
<span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>{days} gün önce</span>
<span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>bugün</span>
</div>
<Card className="bg-surface/50 border-border/50">
<CardHeader className="pb-2">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Günlük Görüntülemeler</CardTitle>
</CardHeader>
<CardContent>
<MiniChart data={stats.daily} days={parseInt(days)} />
<div className="flex justify-between mt-2">
<span className="text-[10px] text-muted font-mono">{days} gün önce</span>
<span className="text-[10px] text-muted font-mono">bugün</span>
</div>
</CardContent>
</Card>
{/* 3-col breakdown */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14, marginBottom: 14 }}>
<div className="grid grid-cols-3 gap-6">
{/* Top pages */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Sayfalar</div>
<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 style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Veri yok</div>
? <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} />)
}
</div>
</CardContent>
</Card>
{/* Referrers */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Trafik Kaynağı</div>
<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 style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Direct / bilinmiyor</div>
? <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(--green)" />
return <Bar key={r.referrer} label={label} value={r.visits} max={stats.topReferrers[0]?.visits ?? 1} color="var(--color-success)" />
})
}
</div>
</CardContent>
</Card>
{/* Countries */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Ülkeler</div>
<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 style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cloudflare proxy gerekli</div>
: stats.byCountry.map(c => <Bar key={c.country} label={c.country} value={c.n} max={stats.byCountry[0]?.n ?? 1} color="var(--yellow)" />)
? <div 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)" />)
}
</div>
</CardContent>
</Card>
</div>
{/* Device / Browser / OS */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14 }}>
<div className="grid grid-cols-3 gap-6">
{[
{ title: 'Cihaz', data: stats.byDevice.map(d => ({ label: d.device, value: d.n })) },
{ title: 'Tarayıcı', data: stats.byBrowser.map(d => ({ label: d.browser, value: d.n })) },
{ title: 'İşletim Sistemi', data: stats.byOs.map(d => ({ label: d.os, value: d.n })) },
].map(section => (
<div key={section.title} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>{section.title}</div>
<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 style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Veri yok</div>
: section.data.map(d => <Bar key={d.label} label={d.label} value={d.value} max={section.data[0]?.value ?? 1} color="rgba(123,97,255,.8)" />)
? <div 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" />)
}
</div>
</CardContent>
</Card>
))}
</div>
</>
</div>
)}
{loading && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}>Yükleniyor...</div>
<div className="py-20 text-center text-muted font-mono text-sm animate-pulse">Yükleniyor...</div>
)}
{/* Snippet modal */}
{showSnippet && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={() => setShowSnippet(false)}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 560, maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Tracking Snippet</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}>
Sitendeki <code style={{ background: 'rgba(0,0,0,.3)', padding: '2px 6px', borderRadius: 4, fontFamily: 'monospace' }}>&lt;head&gt;</code> tagının içine ekle:
</div>
<pre style={{ background: 'rgba(0,0,0,.4)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 16px', fontFamily: 'monospace', fontSize: 12, color: 'var(--accent)', overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all', marginBottom: 16 }}>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowSnippet(false)}>
<Card className="w-full max-w-[560px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
<CardHeader>
<CardTitle>Tracking Snippet</CardTitle>
<CardDescription>
Sitendeki <code className="bg-black/30 px-1.5 py-0.5 rounded border border-border">{'<head>'}</code> tagının içine ekle:
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<pre className="bg-black/40 border border-border rounded-xl p-4 font-mono text-xs text-accent overflow-x-auto whitespace-pre-wrap word-break-all shadow-inner">
{snippet}
</pre>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8, fontFamily: 'monospace' }}>
<span style={{ color: 'var(--green)' }}>data-domain</span> hangi domain olduğunu belirtir, değiştirme
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20, fontFamily: 'monospace' }}>
<span style={{ color: 'var(--green)' }}>defer</span> sayfayı yavaşlatmaz, arka planda yüklenir
<div className="space-y-2 text-xs text-muted font-mono bg-surface-2 p-3 rounded-lg border border-border/50">
<div><span className="text-success font-semibold">data-domain</span> — hangi domain olduğunu belirtir, değiştirme</div>
<div><span className="text-success font-semibold">defer</span> — sayfayı yavaşlatmaz, arka planda yüklenir</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={() => { navigator.clipboard.writeText(snippet); }}
style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
<div className="flex gap-3 pt-2">
<Button onClick={() => { navigator.clipboard.writeText(snippet); }} className="flex-1">
Kopyala
</button>
<button onClick={() => setShowSnippet(false)}
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
</Button>
<Button variant="ghost" onClick={() => setShowSnippet(false)} className="flex-1">
Kapat
</button>
</div>
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Add Site modal */}
{showAddSite && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={() => setShowAddSite(false)}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 400, maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Yeni Site Ekle</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}>
Takip etmek istediğiniz alan adını girin (örn: example.com)
</div>
<input
style={{ width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 20 }}
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowAddSite(false)}>
<Card className="w-full max-w-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
<CardHeader>
<CardTitle>Yeni Site Ekle</CardTitle>
<CardDescription>Takip etmek istediğiniz alan adını girin (örn: example.com)</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder="example.com"
value={newDomain}
onChange={e => setNewDomain(e.target.value)}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter' && newDomain.trim()) {
fetch('/api/analytics/stats', {
@@ -340,8 +391,11 @@ export default function AnalyticsPage() {
}}
/>
<div style={{ display: 'flex', gap: 10 }}>
<button
<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', {
@@ -355,18 +409,15 @@ export default function AnalyticsPage() {
setSelectedDomain(newDomain.trim());
})
}}
style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
className="flex-1"
>
Ekle
</button>
<button onClick={() => setShowAddSite(false)}
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
İptal
</button>
</div>
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}
+309 -136
View File
@@ -1,7 +1,12 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AnimatedTabs } from '@/components/ui/animated-tabs'
import { Plus, X, Database as DatabaseIcon, Download, Play, Table as TableIcon, BarChart2, HardDrive, ShieldAlert, CheckCircle2 } from 'lucide-react'
type Db = { id: string; name: string; host: string; port: number; database: string; username: string; color: string; ssl: boolean }
type Db = { id: string; name: string; host: string; port: number; database: string; username: string; color: string; ssl: boolean; db_type: 'postgres' | 'mysql' }
type Table = { table_name: string; row_count: number; size: string }
type QueryResult = { rows: Record<string, unknown>[]; fields: string[]; error: string | null }
@@ -50,25 +55,38 @@ export default function DatabasesPage() {
}
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
<div className="flex h-screen overflow-hidden bg-background">
{/* LEFT */}
<div style={{ width: 240, background: 'var(--surface)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>Databases</span>
<button onClick={() => setShowAdd(true)} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '4px 10px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer' }}>+ Ekle</button>
<div className="w-64 bg-surface border-r border-border flex flex-col shrink-0">
<div className="p-4 border-b border-border/50 flex justify-between items-center">
<span className="text-xs font-mono text-muted uppercase tracking-widest">Databases</span>
<Button variant="default" size="sm" onClick={() => setShowAdd(true)} className="h-7 text-xs">
<Plus className="w-3.5 h-3.5 mr-1" /> Ekle
</Button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
<div className="flex-1 overflow-y-auto p-3 space-y-1 custom-scrollbar">
{dbs.length === 0
? <div style={{ padding: 20, color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace', textAlign: 'center' }}>Henüz DB yok</div>
? <div className="p-5 text-muted text-xs font-mono text-center">Henüz DB yok</div>
: dbs.map(db => (
<div key={db.id} onClick={() => select(db)} style={{ padding: '10px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 4, background: selected?.id === db.id ? 'rgba(255,255,255,.04)' : 'transparent', border: `1px solid ${selected?.id === db.id ? 'var(--border)' : 'transparent'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: db.color, flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{db.name}</span>
<button onClick={e => { e.stopPropagation(); deleteDb(db.id) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer', flexShrink: 0 }}></button>
<div
key={db.id}
onClick={() => select(db)}
className={`p-3 rounded-xl cursor-pointer transition-all border ${
selected?.id === db.id
? 'bg-accent/5 border-accent/20'
: 'border-transparent hover:bg-surface-2 hover:border-border/50'
}`}
>
<div className="flex items-center gap-2 mb-1.5">
<div className="w-2 h-2 rounded-full shrink-0 shadow-sm" style={{ background: db.color, boxShadow: `0 0 6px ${db.color}` }} />
<span className="text-sm font-semibold flex-1 truncate">{db.name}</span>
<button onClick={e => { e.stopPropagation(); deleteDb(db.id) }} className="text-muted hover:text-destructive transition-colors shrink-0">
<X className="w-3.5 h-3.5" />
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', paddingLeft: 16, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{db.host}/{db.database}
<div className="flex justify-between items-center text-[10px] text-muted font-mono pl-4">
<span className="truncate mr-2">{db.host}/{db.database}</span>
<span className="bg-white/5 px-1.5 py-0.5 rounded text-[9px] uppercase">{db.db_type === 'mysql' ? 'MY' : 'PG'}</span>
</div>
</div>
))}
@@ -76,90 +94,149 @@ export default function DatabasesPage() {
</div>
{/* RIGHT */}
<div style={{ flex: 1, overflow: 'auto' }}>
<div className="flex-1 overflow-auto custom-scrollbar relative">
{!selected
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}> DB seç</div>
? (
<div className="absolute inset-0 flex items-center justify-center text-muted font-mono text-sm opacity-50">
<DatabaseIcon className="w-5 h-5 mr-3" />
Sol menüden bir veritabanı seçin
</div>
)
: (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: selected.color }} />
<h2 style={{ fontSize: 18, fontWeight: 800 }}>{selected.name}</h2>
<div className="p-8 max-w-6xl mx-auto fade-up">
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="w-3 h-3 rounded-full" style={{ background: selected.color, boxShadow: `0 0 10px ${selected.color}` }} />
<h2 className="text-2xl font-extrabold tracking-tight">{selected.name}</h2>
</div>
<div className="text-sm text-muted font-mono bg-surface-2 w-fit px-3 py-1 rounded-md border border-border/50">
{selected.username}@{selected.host}:{selected.port}/{selected.database}
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>{selected.username}@{selected.host}:{selected.port}/{selected.database}</div>
</div>
{/* Stats row */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, marginBottom: 20 }}>
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ l: 'Boyut', v: String(stats.size) },
{ l: 'Aktif Bağlantı', v: String(stats.active_connections) },
{ l: 'Tablo Sayısı', v: String(stats.table_count) },
{ l: 'Boyut', v: String(stats.size), icon: HardDrive },
{ l: 'Aktif Bağlantı', v: String(stats.active_connections), icon: ActivityIcon },
{ l: 'Tablo Sayısı', v: String(stats.table_count), icon: TableIcon },
].map(s => (
<div key={s.l} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }}>{s.l}</div>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--accent)' }}>{s.v}</div>
<Card key={s.l} className="bg-surface/30">
<CardContent className="p-5 flex items-start justify-between">
<div>
<div className="text-[10px] text-muted font-mono uppercase tracking-wider mb-2">{s.l}</div>
<div className="text-2xl font-extrabold text-accent">{s.v}</div>
</div>
<s.icon className="w-5 h-5 text-accent/30" />
</CardContent>
</Card>
))}
</div>
)}
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 16, gap: 4 }}>
{(['tables', 'query', 'stats', 'backups'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{ padding: '8px 16px', fontSize: 13, fontWeight: 600, color: tab === t ? 'var(--accent)' : 'var(--muted)', background: 'none', border: 'none', borderBottomWidth: 2, borderBottomStyle: 'solid', borderBottomColor: tab === t ? 'var(--accent)' : 'transparent', cursor: 'pointer', marginBottom: -1 }}>
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : t === 'stats' ? 'İstatistik' : 'Yedekler'}
</button>
))}
<div className="mb-6">
<AnimatedTabs
activeTab={tab}
onChange={(t: any) => setTab(t)}
tabs={[
{ id: 'tables', label: 'Tablolar', icon: <TableIcon className="w-4 h-4" /> },
{ id: 'query', label: 'Sorgu', icon: <Play className="w-4 h-4" /> },
{ id: 'stats', label: 'İstatistik', icon: <BarChart2 className="w-4 h-4" /> },
{ id: 'backups', label: 'Yedekler', icon: <Download className="w-4 h-4" /> },
]}
/>
</div>
{/* Tables */}
{tab === 'tables' && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<Card className="overflow-hidden">
{tables.length === 0
? <div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Tablo bulunamadı veya yükleniyor...</div>
: <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>{['Tablo','Satır','Boyut'].map(h => <th key={h} style={{ padding: '9px 16px', fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textAlign: 'left', textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,.2)' }}>{h}</th>)}</tr></thead>
? <div className="p-6 text-muted font-mono text-sm text-center">Tablo bulunamadı veya yükleniyor...</div>
: <div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr>
{['Tablo','Satır','Boyut'].map(h => (
<th key={h} className="px-6 py-3 text-[10px] font-mono text-muted uppercase tracking-wider border-b border-border bg-black/20">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{tables.map(t => (
<tr key={t.table_name} style={{ cursor: 'pointer' }} onClick={() => { setTab('query'); setSql(`SELECT * FROM ${t.table_name} LIMIT 50`) }}>
<td style={{ padding: '10px 16px', fontSize: 13, fontFamily: 'monospace', color: 'var(--accent)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{t.table_name}</td>
<td style={{ padding: '10px 16px', fontSize: 12, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{Number(t.row_count).toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{t.size}</td>
{tables.map((t, i) => (
<tr
key={t.table_name}
onClick={() => { setTab('query'); setSql(`SELECT * FROM ${t.table_name} LIMIT 50`) }}
className="group cursor-pointer hover:bg-white/[0.02] transition-colors"
>
<td className={`px-6 py-4 text-sm font-mono text-accent ${i !== tables.length-1 ? 'border-b border-border/50' : ''}`}>
{t.table_name}
</td>
<td className={`px-6 py-4 text-sm font-mono ${i !== tables.length-1 ? 'border-b border-border/50' : ''}`}>
{Number(t.row_count).toLocaleString()}
</td>
<td className={`px-6 py-4 text-sm font-mono text-muted ${i !== tables.length-1 ? 'border-b border-border/50' : ''}`}>
{t.size}
</td>
</tr>
))}
</tbody>
</table>
}
</div>
}
</Card>
)}
{/* Query */}
{tab === 'query' && (
<div>
<textarea value={sql} onChange={e => setSql(e.target.value)} rows={5}
<div className="space-y-4 fade-up">
<div className="relative">
<textarea
value={sql}
onChange={e => setSql(e.target.value)}
rows={5}
onKeyDown={e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') runQuery() }}
style={{ width: '100%', background: 'rgba(0,0,0,.4)', border: '1px solid var(--border)', borderRadius: 8, padding: '12px 14px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', resize: 'vertical', outline: 'none', marginBottom: 8 }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Cmd+Enter · Sadece SELECT</span>
<button onClick={runQuery} disabled={qLoading} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 20px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
{qLoading ? '...' : '▶ Çalıştır'}
</button>
className="w-full bg-black/40 border border-border rounded-xl p-4 text-sm font-mono text-text resize-y outline-none focus:ring-1 focus:ring-accent/50 transition-all shadow-inner"
/>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted font-mono bg-surface-2 px-2 py-1 rounded">Cmd+Enter · Sadece SELECT</span>
<Button onClick={runQuery} disabled={qLoading}>
<Play className="w-4 h-4 mr-2" />
{qLoading ? 'Çalışıyor...' : 'Çalıştır'}
</Button>
</div>
{result && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<Card className="overflow-hidden fade-up">
{result.error
? <div style={{ padding: 14, color: 'var(--red)', fontFamily: 'monospace', fontSize: 12 }}> {result.error}</div>
? <div className="p-4 text-destructive font-mono text-sm bg-destructive/5 flex items-center gap-2"><ShieldAlert className="w-4 h-4"/> {result.error}</div>
: <>
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--border)', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{result.rows.length} satır</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>{result.fields.map(f => <th key={f} style={{ padding: '8px 14px', fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textAlign: 'left', textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,.2)', whiteSpace: 'nowrap' }}>{f}</th>)}</tr></thead>
<div className="px-5 py-2.5 border-b border-border text-xs text-muted font-mono bg-black/20">
{result.rows.length} satır getirildi
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
{result.fields.map(f => (
<th key={f} className="px-5 py-3 text-[10px] text-muted font-mono text-left uppercase tracking-wider border-b border-border bg-black/20 whitespace-nowrap">
{f}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.slice(0, 200).map((row, i) => (
<tr key={i}>
{result.fields.map(f => <td key={f} style={{ padding: '8px 14px', fontSize: 12, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)', whiteSpace: 'nowrap', color: row[f] == null ? 'var(--muted)' : 'var(--text)' }}>{row[f] == null ? 'NULL' : String(row[f])}</td>)}
<tr key={i} className="hover:bg-white/[0.01]">
{result.fields.map(f => (
<td key={f} className={`px-5 py-3 text-xs font-mono whitespace-nowrap ${i !== Math.min(result.rows.length, 200)-1 ? 'border-b border-border/30' : ''} ${row[f] == null ? 'text-muted/50' : 'text-text/90'}`}>
{row[f] == null ? 'NULL' : String(row[f])}
</td>
))}
</tr>
))}
</tbody>
@@ -167,16 +244,16 @@ export default function DatabasesPage() {
</div>
</>
}
</div>
</Card>
)}
</div>
)}
{/* Stats detail */}
{tab === 'stats' && stats && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
<pre style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--muted)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(stats, null, 2)}</pre>
</div>
<Card className="p-6 fade-up">
<pre className="font-mono text-xs text-muted/80 whitespace-pre-wrap leading-relaxed">{JSON.stringify(stats, null, 2)}</pre>
</Card>
)}
{/* Backups */}
@@ -191,7 +268,7 @@ export default function DatabasesPage() {
}
function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
const [f, setF] = useState({ name: '', host: 'localhost', port: 5432, database: '', username: 'postgres', password: '', ssl: false, color: '#00e5ff' })
const [f, setF] = useState({ name: '', host: 'localhost', port: 5432, database: '', username: 'postgres', password: '', ssl: false, color: '#00e5ff', db_type: 'postgres' as 'postgres' | 'mysql' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [connString, setConnString] = useState('')
@@ -200,13 +277,15 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
setConnString(val)
try {
const u = new URL(val)
if (u.protocol === 'postgres:' || u.protocol === 'postgresql:') {
if (u.protocol === 'postgres:' || u.protocol === 'postgresql:' || u.protocol === 'mysql:') {
const isMysql = u.protocol === 'mysql:'
setF(p => ({
...p,
db_type: isMysql ? 'mysql' : 'postgres',
host: u.hostname || p.host,
port: parseInt(u.port) || 5432,
port: parseInt(u.port) || (isMysql ? 3306 : 5432),
database: u.pathname.slice(1) || p.database,
username: u.username || p.username,
username: u.username || (isMysql ? 'root' : 'postgres'),
password: decodeURIComponent(u.password) || p.password,
ssl: u.searchParams.get('sslmode') !== 'disable' && u.searchParams.get('sslmode') !== null ? true : p.ssl
}))
@@ -218,7 +297,6 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
const submit = async () => {
setLoading(true); setError('')
// Test bağlantısı
try {
const testRes = await fetch('/api/db', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', db: { ...f, id: '__test__' } }) })
const testData = await testRes.json()
@@ -233,46 +311,90 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
return
}
// Config'e kaydet
const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'database', item: f }) })
if (res.ok) { onAdded() } else { setError((await res.json()).error ?? 'Hata'); setLoading(false) }
}
const inp: React.CSSProperties = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none' }
const lbl: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 420 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 20 }}>Yeni DB Bağlantısı</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div><label style={lbl}>Bağlantı URL (Opsiyonel)</label><input style={inp} placeholder="postgres://user:pass@host:5432/db" value={connString} onChange={e => handleConnectionString(e.target.value)} /></div>
<div><label style={lbl}>Ad</label><input style={inp} placeholder="Kotekli Prod" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px', gap: 8 }}>
<div><label style={lbl}>Host</label><input style={inp} value={f.host} onChange={e => setF(p => ({ ...p, host: e.target.value }))} /></div>
<div><label style={lbl}>Port</label><input style={inp} type="number" value={f.port} onChange={e => setF(p => ({ ...p, port: parseInt(e.target.value) }))} /></div>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[440px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/50">
<CardTitle>Yeni Veritabanı Bağlantısı</CardTitle>
<CardDescription>Veritabanı erişimi için gerekli bilgileri girin.</CardDescription>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Bağlantı URL (Opsiyonel)</label>
<Input placeholder="postgres://... veya mysql://..." value={connString} onChange={e => handleConnectionString(e.target.value)} />
</div>
<div><label style={lbl}>Database</label><input style={inp} placeholder="mydb" value={f.database} onChange={e => setF(p => ({ ...p, database: e.target.value }))} /></div>
<div><label style={lbl}>Username</label><input style={inp} value={f.username} onChange={e => setF(p => ({ ...p, username: e.target.value }))} /></div>
<div><label style={lbl}>Password</label><input type="password" style={inp} value={f.password} onChange={e => setF(p => ({ ...p, password: e.target.value }))} /></div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--muted)', cursor: 'pointer' }}>
<input type="checkbox" checked={f.ssl} onChange={e => setF(p => ({ ...p, ssl: e.target.checked }))} /> SSL
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Tür</label>
<select
className="flex h-9 w-full rounded-md border border-border bg-black/30 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent font-mono appearance-none"
value={f.db_type}
onChange={e => {
const t = e.target.value as 'postgres' | 'mysql'
setF(p => ({ ...p, db_type: t, port: t === 'mysql' ? 3306 : 5432, username: t === 'mysql' ? 'root' : 'postgres' }))
}}
>
<option value="postgres">PostgreSQL</option>
<option value="mysql">MySQL</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Ad</label>
<Input placeholder="Kotekli Prod" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-[1fr_80px] gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Host</label>
<Input value={f.host} onChange={e => setF(p => ({ ...p, host: e.target.value }))} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Port</label>
<Input type="number" value={f.port} onChange={e => setF(p => ({ ...p, port: parseInt(e.target.value) }))} />
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Database</label>
<Input placeholder="mydb" value={f.database} onChange={e => setF(p => ({ ...p, database: e.target.value }))} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Username</label>
<Input value={f.username} onChange={e => setF(p => ({ ...p, username: e.target.value }))} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Password</label>
<Input type="password" value={f.password} onChange={e => setF(p => ({ ...p, password: e.target.value }))} />
</div>
<div className="flex justify-between items-center pt-2">
<label className="flex items-center gap-2 text-sm text-muted cursor-pointer hover:text-text transition-colors">
<input type="checkbox" checked={f.ssl} onChange={e => setF(p => ({ ...p, ssl: e.target.checked }))} className="rounded border-border bg-black/30 w-4 h-4 accent-accent" />
SSL Kullan
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>RENK</span>
<input type="color" value={f.color} onChange={e => setF(p => ({ ...p, color: e.target.value }))} style={{ width: 30, height: 26, borderRadius: 4, border: '1px solid var(--border)', cursor: 'pointer', background: 'transparent' }} />
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted uppercase">Renk</span>
<input type="color" value={f.color} onChange={e => setF(p => ({ ...p, color: e.target.value }))} className="w-8 h-8 rounded cursor-pointer bg-transparent border-0 p-0" />
</div>
</div>
</div>
{error && <div style={{ color: 'var(--red)', fontSize: 12, fontFamily: 'monospace', marginTop: 10 }}> {error}</div>}
<div style={{ display: 'flex', gap: 10, marginTop: 22 }}>
<button onClick={onClose} style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>İptal</button>
<button onClick={submit} disabled={loading || !f.name || !f.database} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
{error && <div className="text-destructive text-xs font-mono bg-destructive/10 p-2 rounded-md border border-destructive/20 mt-2 flex items-center gap-2"><ShieldAlert className="w-3.5 h-3.5"/>{error}</div>}
<div className="flex gap-3 pt-4 border-t border-border/50 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button>
<Button onClick={submit} disabled={loading || !f.name || !f.database} className="flex-1">
{loading ? 'Kaydediliyor...' : 'Kaydet'}
</button>
</div>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -282,7 +404,7 @@ function BackupTab({ db }: { db: Db }) {
const [logs, setLogs] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '', gdrive_folder_id: '' })
const load = useCallback(async () => {
setLoading(true)
@@ -292,7 +414,7 @@ function BackupTab({ db }: { db: Db }) {
setConfig(data.config)
setLogs(data.logs)
if (data.config) {
setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials })
setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials, gdrive_folder_id: data.config.gdrive_folder_id || '' })
}
}
setLoading(false)
@@ -319,65 +441,116 @@ function BackupTab({ db }: { db: Db }) {
body: JSON.stringify({ db_id: db.id })
})
setConfig(null)
setF({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
setF({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '', gdrive_folder_id: '' })
}
if (loading) return <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Yükleniyor...</div>
const inp = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 12 }
const lbl = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 } as any
if (loading) return <div className="text-muted text-sm font-mono animate-pulse">Yükleniyor...</div>
return (
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
<div style={{ flex: 1, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 700 }}>Otomatik Yedekleme</h3>
<a href={`/api/db/backup?dbId=${db.id}`} download style={{ background: 'rgba(255,255,255,.1)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 12px', color: 'var(--text)', fontSize: 12, textDecoration: 'none' }}>
Hemen İndir
<div className="flex gap-6 items-start fade-up">
<Card className="flex-1">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div>
<CardTitle className="text-lg">Otomatik Yedekleme</CardTitle>
<CardDescription>Düzenli veritabanı yedekleri alın ve buluta gönderin.</CardDescription>
</div>
<a href={`/api/db/backup?dbId=${db.id}`} download className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-xs font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 border border-border bg-transparent hover:bg-surface-2 text-text h-8 px-3">
<Download className="w-3.5 h-3.5 mr-1.5" />
Hemen İndir
</a>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Cron Schedule (node-cron)</label>
<Input value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" />
</div>
<div><label style={lbl}>Cron Schedule (node-cron)</label><input style={inp} value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" /></div>
<div style={{ marginBottom: 12 }}>
<label style={lbl}>Cloud Sağlayıcı</label>
<select style={inp as any} value={f.cloud_type} onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Cloud Sağlayıcı</label>
<select
className="flex h-9 w-full rounded-md border border-border bg-black/30 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent font-mono appearance-none"
value={f.cloud_type}
onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}
>
<option value="dropbox">Dropbox</option>
<option value="gdrive">Google Drive</option>
</select>
</div>
<div>
<label style={lbl}>Credentials (JSON veya Token)</label>
<textarea style={{ ...inp, resize: 'vertical' } as any} rows={4} value={f.credentials} onChange={e => setF(p => ({ ...p, credentials: e.target.value }))} placeholder={f.cloud_type === 'dropbox' ? 'Dropbox Access Token buraya...' : 'Google Service Account JSON buraya...'} />
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Credentials (JSON veya Token)</label>
<textarea
className="flex w-full rounded-md border border-border bg-black/30 px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent font-mono resize-y min-h-[100px]"
value={f.credentials}
onChange={e => setF(p => ({ ...p, credentials: e.target.value }))}
placeholder={f.cloud_type === 'dropbox' ? 'Dropbox Access Token buraya...' : 'Google Service Account JSON buraya...'}
/>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={saveConfig} disabled={saving} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>{saving ? 'Kaydediliyor...' : 'Kaydet'}</button>
{f.cloud_type === 'gdrive' && (
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Klasör ID (Opsiyonel)</label>
<Input value={f.gdrive_folder_id} onChange={e => setF(p => ({ ...p, gdrive_folder_id: e.target.value }))} placeholder="Örn: 1A2b3C4d5E6f7G8h9I0j..." />
<div className="text-[10px] text-muted/80 font-mono pt-1">
Service account e-posta adresini, kendi Drive'ınızda açtığınız bir klasöre "Düzenleyici" olarak ekleyin ve klasörün ID'sini buraya yapıştırın.
</div>
</div>
)}
<div className="flex gap-3 pt-2">
<Button onClick={saveConfig} disabled={saving} className="flex-1">
{saving ? 'Kaydediliyor...' : 'Kaydet'}
</Button>
{config && (
<button onClick={deleteConfig} style={{ background: 'rgba(255,0,0,.1)', border: '1px solid rgba(255,0,0,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--red)', cursor: 'pointer', fontSize: 13 }}>Kapat</button>
<Button variant="danger" onClick={deleteConfig}>Kapat</Button>
)}
</div>
</div>
</CardContent>
</Card>
<div style={{ width: 340, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>Geçmiş İşlemler</h3>
{logs.length === 0 ? <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Kayıt yok</div> : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Card className="w-80 shrink-0">
<CardHeader className="pb-4">
<CardTitle className="text-sm">Geçmiş İşlemler</CardTitle>
</CardHeader>
<CardContent>
{logs.length === 0 ? <div className="text-muted text-xs font-mono">Kayıt yok</div> : (
<div className="flex flex-col gap-2.5">
{logs.map((log: any) => (
<div key={log.id} style={{ padding: 12, background: 'rgba(0,0,0,.2)', borderRadius: 6, borderLeft: `3px solid ${log.status === 'success' ? '#00e676' : 'var(--red)'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: log.status === 'success' ? '#00e676' : 'var(--red)' }}>{log.status.toUpperCase()}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{new Date(log.ts * 1000).toLocaleString()}</span>
<div key={log.id} className={`p-3 rounded-lg border bg-surface/50 ${log.status === 'success' ? 'border-l-2 border-l-success border-border/50' : 'border-l-2 border-l-destructive border-border/50'}`}>
<div className="flex justify-between items-center mb-1">
<span className={`text-[10px] font-bold uppercase tracking-wider ${log.status === 'success' ? 'text-success' : 'text-destructive flex items-center gap-1'}`}>
{log.status === 'success' ? <CheckCircle2 className="w-3 h-3 inline mr-1"/> : <ShieldAlert className="w-3 h-3 inline mr-1"/>}
{log.status}
</span>
<span className="text-[10px] text-muted font-mono">{new Date(log.ts * 1000).toLocaleString()}</span>
</div>
{log.status === 'success' && <div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Boyut: {(log.file_size / 1024).toFixed(2)} KB</div>}
{log.status !== 'success' && <div style={{ fontSize: 11, color: 'var(--red)', fontFamily: 'monospace', marginTop: 4 }}>{log.message}</div>}
{log.status === 'success' && <div className="text-xs text-muted/80 font-mono mt-2">Boyut: {(log.file_size / 1024).toFixed(2)} KB</div>}
{log.status !== 'success' && <div className="text-[11px] text-destructive/90 font-mono mt-1.5 leading-tight">{log.message}</div>}
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
</div>
)
}
function ActivityIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
)
}
+240
View File
@@ -0,0 +1,240 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, X, Box, ShieldAlert, CheckCircle2, Play, Square, RotateCw } from 'lucide-react'
type DockerHost = { id: string; name: string; url: string }
type Container = {
Id: string
Names: string[]
Image: string
State: string
Status: string
Ports: { PrivatePort: number; PublicPort?: number; Type: string }[]
}
export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[] }) {
const [hosts, setHosts] = useState<DockerHost[]>(initialHosts)
const [selected, setSelected] = useState<DockerHost | null>(null)
const [containers, setContainers] = useState<Container[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showAdd, setShowAdd] = useState(false)
const loadHosts = useCallback(async () => {
const r = await fetch('/api/config')
const cfg = await r.json()
if (cfg.docker_hosts) setHosts(cfg.docker_hosts)
}, [])
const select = async (host: DockerHost) => {
setSelected(host)
setContainers([])
setError('')
setLoading(true)
try {
const r = await fetch(`/api/docker?hostId=${host.id}`)
const data = await r.json()
if (r.ok) {
setContainers(data)
} else {
setError(data.error || 'Bağlantı hatası')
}
} catch (e: any) {
setError(e.message)
}
setLoading(false)
}
const deleteHost = async (id: string) => {
if (!confirm('Docker bağlantısını sil?')) return
await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', id }) })
if (selected?.id === id) setSelected(null)
loadHosts()
}
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* LEFT */}
<div className="w-64 bg-surface border-r border-border flex flex-col shrink-0">
<div className="p-4 border-b border-border/50 flex justify-between items-center">
<span className="text-xs font-mono text-muted uppercase tracking-widest">Docker Hosts</span>
<Button variant="default" size="sm" onClick={() => setShowAdd(true)} className="h-7 text-xs">
<Plus className="w-3.5 h-3.5 mr-1" /> Ekle
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1 custom-scrollbar">
{hosts.length === 0
? <div className="p-5 text-muted text-xs font-mono text-center">Henüz Host yok</div>
: hosts.map(host => (
<div
key={host.id}
onClick={() => select(host)}
className={`p-3 rounded-xl cursor-pointer transition-all border ${
selected?.id === host.id
? 'bg-accent/5 border-accent/20'
: 'border-transparent hover:bg-surface-2 hover:border-border/50'
}`}
>
<div className="flex items-center gap-2 mb-1.5">
<Box className="w-4 h-4 text-accent" />
<span className="text-sm font-semibold flex-1 truncate">{host.name}</span>
<button onClick={e => { e.stopPropagation(); deleteHost(host.id) }} className="text-muted hover:text-destructive transition-colors shrink-0">
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex justify-between items-center text-[10px] text-muted font-mono pl-6">
<span className="truncate mr-2">{host.url}</span>
</div>
</div>
))}
</div>
</div>
{/* RIGHT */}
<div className="flex-1 overflow-auto custom-scrollbar relative">
{!selected
? (
<div className="absolute inset-0 flex items-center justify-center text-muted font-mono text-sm opacity-50">
<Box className="w-5 h-5 mr-3" />
Sol menüden bir Docker sunucusu seçin
</div>
)
: (
<div className="p-8 max-w-6xl mx-auto fade-up">
<div className="mb-8 flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<Box className="w-6 h-6 text-accent" />
<h2 className="text-2xl font-extrabold tracking-tight">{selected.name}</h2>
</div>
<div className="text-sm text-muted font-mono bg-surface-2 w-fit px-3 py-1 rounded-md border border-border/50">
{selected.url}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => select(selected)} disabled={loading}>
<RotateCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Yenile
</Button>
</div>
{error && (
<div className="p-4 text-destructive font-mono text-sm bg-destructive/5 flex items-center gap-2 border border-destructive/20 rounded-xl mb-6">
<ShieldAlert className="w-4 h-4"/> {error}
</div>
)}
{/* Containers */}
{!error && (
<Card className="overflow-hidden">
{loading && containers.length === 0
? <div className="p-6 text-muted font-mono text-sm text-center animate-pulse">Konteynerler yükleniyor...</div>
: containers.length === 0
? <div className="p-6 text-muted font-mono text-sm text-center">Konteyner bulunamadı.</div>
: <div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr>
{['İsim', 'İmaj', 'Durum', 'Portlar'].map(h => (
<th key={h} className="px-6 py-3 text-[10px] font-mono text-muted uppercase tracking-wider border-b border-border bg-black/20">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{containers.map((c, i) => {
const name = c.Names?.[0]?.replace(/^\//, '') || 'Bilinmiyor'
const isRunning = c.State === 'running'
return (
<tr
key={c.Id}
className="group hover:bg-white/[0.02] transition-colors"
>
<td className={`px-6 py-4 text-sm font-semibold text-accent ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
{name}
</td>
<td className={`px-6 py-4 text-xs font-mono text-muted max-w-[200px] truncate ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
{c.Image}
</td>
<td className={`px-6 py-4 text-xs font-mono ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isRunning ? 'bg-success shadow-[0_0_8px_var(--color-success)]' : 'bg-muted'}`} />
<span className={isRunning ? 'text-success' : 'text-muted'}>{c.Status}</span>
</div>
</td>
<td className={`px-6 py-4 text-[10px] font-mono text-muted ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
<div className="flex flex-wrap gap-1">
{c.Ports?.map((p, idx) => (
<span key={idx} className="bg-surface-2 px-1.5 py-0.5 rounded border border-border/50">
{p.PublicPort ? `${p.PublicPort}->${p.PrivatePort}` : p.PrivatePort}/{p.Type}
</span>
))}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
}
</Card>
)}
</div>
)}
</div>
{showAdd && <AddHostModal onClose={() => setShowAdd(false)} onAdded={() => { loadHosts(); setShowAdd(false) }} />}
</div>
)
}
function AddHostModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
const [f, setF] = useState({ name: '', url: 'http://' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const submit = async () => {
setLoading(true); setError('')
try {
const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', item: f }) })
if (res.ok) { onAdded() } else { setError((await res.json()).error ?? 'Hata'); setLoading(false) }
} catch (e) {
setError('Bağlantı eklenemedi.')
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/50">
<CardTitle>Yeni Docker Host</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Sunucu Adı</label>
<Input placeholder="Remote VPS" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Docker API URL</label>
<Input placeholder="http://192.168.1.10:2375" value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} />
<p className="text-[10px] text-muted font-mono mt-1">Docker Daemon'ın TCP üzerinden erişilebilir olduğundan emin olun.</p>
</div>
{error && <div className="text-destructive text-xs font-mono bg-destructive/10 p-2 rounded-md border border-destructive/20 mt-2 flex items-center gap-2"><ShieldAlert className="w-3.5 h-3.5"/>{error}</div>}
<div className="flex gap-3 pt-4 border-t border-border/50 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button>
<Button onClick={submit} disabled={loading || !f.name || !f.url} className="flex-1">
{loading ? 'Ekleniyor...' : 'Ekle'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { readConfig } from '@/lib/config'
import ClientPage from './ClientPage'
export const dynamic = 'force-dynamic'
export default async function DockerPage() {
const config = await readConfig()
return <ClientPage initialHosts={config.docker_hosts || []} />
}
+27 -18
View File
@@ -1,5 +1,22 @@
@import "tailwindcss";
@theme {
--color-background: #0d0f14;
--color-surface: #13161f;
--color-surface-2: #1a1f2e;
--color-border: rgba(255, 255, 255, 0.07);
--color-accent: #00e5ff;
--color-accent-transparent: rgba(0, 229, 255, 0.1);
--color-success: #00e676;
--color-warning: #ffab00;
--color-destructive: #ff3d71;
--color-text: #e2e8f0;
--color-muted: #8b95a5;
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-fade-up: fadeUp 0.3s ease-out forwards;
}
:root {
--bg: #0d0f14;
--surface: #13161f;
@@ -11,34 +28,26 @@
--red: #ff3d71;
--purple: #7b61ff;
--text: #e2e8f0;
--muted: #4a5270;
--muted: #8b95a5;
}
* { box-sizing: border-box; }
body {
background: var(--bg);
background-color: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
margin: 0;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0,229,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,229,255,0.02) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
/* Base custom scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a3050; border-radius: 2px; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
@keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
.fade-up { animation: fadeUp .25s ease forwards; }
@keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
.fade-up { animation: fadeUp .3s ease forwards; }
+62 -17
View File
@@ -1,6 +1,11 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { GridPattern } from '@/components/ui/grid-pattern'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Lock, ArrowRight, XCircle } from 'lucide-react'
export default function LoginPage() {
const router = useRouter()
@@ -17,25 +22,65 @@ export default function LoginPage() {
}
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 16, padding: '40px 36px', width: 360 }}>
<div style={{ marginBottom: 32 }}>
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--accent)', letterSpacing: -0.5 }}>VPS Panel</div>
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>self-hosted ops dashboard</div>
</div>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<input
type="password" value={pw} onChange={e => setPw(e.target.value)}
placeholder="Şifre" autoFocus
style={{ background: 'rgba(0,0,0,.3)', border: `1px solid ${err ? 'var(--red)' : 'var(--border)'}`, borderRadius: 8, padding: '11px 14px', color: 'var(--text)', fontSize: 14, fontFamily: 'monospace', outline: 'none', width: '100%' }}
<div className="min-h-screen flex items-center justify-center relative z-10 bg-background overflow-hidden">
<GridPattern
squares={[
[4, 4],
[5, 1],
[8, 2],
[5, 5],
[10, 10],
[12, 15],
[15, 10],
[10, 15],
]}
className="fill-white/[0.01] stroke-white/[0.02]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-background to-transparent pointer-events-none" />
<Card className="w-full max-w-[380px] shadow-2xl shadow-accent/5 fade-up border-white/10 bg-surface/80 backdrop-blur-xl relative">
<div className="absolute -top-px left-1/2 -translate-x-1/2 w-3/4 h-px bg-gradient-to-r from-transparent via-accent/50 to-transparent" />
<CardHeader className="text-center pb-8 pt-10">
<CardTitle className="text-3xl font-extrabold tracking-tight mb-2">VPS Panel</CardTitle>
<CardDescription className="uppercase tracking-[0.15em] text-[10px]">Self-hosted ops dashboard</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={login} className="flex flex-col gap-4">
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<Input
type="password"
value={pw}
onChange={e => setPw(e.target.value)}
placeholder="Şifre"
autoFocus
className="pl-10 h-11"
/>
{err && <div style={{ color: 'var(--red)', fontSize: 12, fontFamily: 'monospace' }}> {err}</div>}
<button type="submit" disabled={loading || !pw}
style={{ background: 'rgba(0,229,255,.12)', border: '1px solid rgba(0,229,255,.35)', borderRadius: 8, padding: '11px', color: 'var(--accent)', fontSize: 14, fontWeight: 600, cursor: 'pointer', opacity: !pw ? .5 : 1 }}>
{loading ? 'Giriş yapılıyor...' : 'Giriş Yap →'}
</button>
</form>
</div>
{err && (
<div className="flex items-center gap-2 text-destructive text-xs font-mono bg-destructive/10 p-2 rounded-md border border-destructive/20">
<XCircle className="w-3.5 h-3.5" />
{err}
</div>
)}
<Button
type="submit"
disabled={loading || !pw}
className="w-full h-11 mt-2 text-[15px] group"
>
{loading ? 'Giriş yapılıyor...' : (
<>
Giriş Yap
<ArrowRight className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" />
</>
)}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
+46 -27
View File
@@ -1,12 +1,16 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { LayoutDashboard, Activity, Database, LineChart, Server, LogOut, Box } from 'lucide-react'
import { cn } from '@/lib/utils'
const nav = [
{ href: '/dashboard', label: 'Overview', emoji: '⊞' },
{ href: '/dashboard/uptime', label: 'Uptime', emoji: '◎' },
{ href: '/dashboard/databases', label: 'Databases', emoji: '⊙' },
{ href: '/dashboard/analytics', label: 'Analytics', emoji: '◈' },
{ href: '/dashboard/services', label: 'Servisler', emoji: '⊛' },
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
{ href: '/dashboard/uptime', label: 'Uptime', icon: Activity },
{ href: '/dashboard/databases', label: 'Databases', icon: Database },
{ href: '/dashboard/analytics', label: 'Analytics', icon: LineChart },
{ href: '/dashboard/services', label: 'Servisler', icon: Server },
{ href: '/dashboard/docker', label: 'Docker', icon: Box },
]
export default function Sidebar() {
@@ -19,40 +23,55 @@ export default function Sidebar() {
}
return (
<nav style={{ width: 210, background: 'var(--surface)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', padding: '20px 0', position: 'sticky', top: 0, height: '100vh', flexShrink: 0 }}>
<div style={{ padding: '0 16px 20px', borderBottom: '1px solid var(--border)', marginBottom: 12 }}>
<div style={{ fontSize: 16, fontWeight: 800, color: 'var(--accent)' }}>VPS Panel</div>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 2, letterSpacing: 1 }}>OPS DASHBOARD</div>
<nav className="w-64 bg-surface border-r border-border flex flex-col py-6 sticky top-0 h-screen shrink-0 z-20">
<div className="px-6 pb-6 border-b border-border/50 mb-4">
<div className="text-lg font-extrabold text-accent flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-accent/20 border border-accent/40 flex items-center justify-center">
<span className="w-2 h-2 rounded-full bg-accent animate-pulse" />
</div>
VPS Panel
</div>
<div className="text-[10px] text-muted font-mono mt-1.5 tracking-widest pl-8">OPS DASHBOARD</div>
</div>
<div style={{ flex: 1 }}>
<div className="flex-1 px-3 space-y-1">
{nav.map(item => {
const active = item.href === '/dashboard' ? path === item.href : path.startsWith(item.href)
const Icon = item.icon
return (
<a key={item.href} href={item.href} style={{
display: 'flex', alignItems: 'center', gap: 9,
padding: '9px 16px', fontSize: 13, fontWeight: 600,
color: active ? 'var(--accent)' : 'var(--muted)',
borderLeft: `2px solid ${active ? 'var(--accent)' : 'transparent'}`,
background: active ? 'rgba(0,229,255,.05)' : 'transparent',
textDecoration: 'none', transition: 'all .15s',
}}>
<span style={{ fontSize: 15 }}>{item.emoji}</span>
<a
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all group",
active
? "bg-accent/10 text-accent"
: "text-muted hover:text-text hover:bg-surface-2"
)}
>
<Icon className={cn("w-4 h-4 transition-transform", active ? "scale-110" : "group-hover:scale-110")} />
{item.label}
</a>
)
})}
</div>
<div style={{ padding: '16px', borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--green)', display: 'inline-block', boxShadow: '0 0 6px var(--green)', animation: 'pulse 2s infinite' }} />
aktif
<div className="p-4 mx-3 mt-auto border-t border-border/50">
<div className="flex items-center gap-2 text-xs text-muted font-mono mb-4 px-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-success shadow-[0_0_8px_var(--color-success)]"></span>
</span>
Sistem Aktif
</div>
<button onClick={logout} style={{ width: '100%', background: 'transparent', border: '1px solid var(--border)', borderRadius: 6, padding: '7px', color: 'var(--muted)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace', transition: 'all .2s' }}
onMouseOver={e => { const t = e.currentTarget; t.style.color = 'var(--red)'; t.style.borderColor = 'var(--red)' }}
onMouseOut={e => { const t = e.currentTarget; t.style.color = 'var(--muted)'; t.style.borderColor = 'var(--border)' }}>
Çıkış
<button
onClick={logout}
className="w-full flex items-center justify-center gap-2 bg-transparent border border-border rounded-lg py-2 text-muted text-xs font-mono transition-all hover:text-destructive hover:border-destructive hover:bg-destructive/10 group"
>
<LogOut className="w-3.5 h-3.5 transition-transform group-hover:-translate-x-1" />
Çıkış Yap
</button>
</div>
</nav>
+46
View File
@@ -0,0 +1,46 @@
'use client'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
interface Tab {
id: string
label: string
icon?: React.ReactNode
}
interface AnimatedTabsProps {
tabs: Tab[]
activeTab: string
onChange: (id: string) => void
className?: string
}
export function AnimatedTabs({ tabs, activeTab, onChange, className }: AnimatedTabsProps) {
return (
<div className={cn("flex space-x-1 rounded-xl bg-black/20 p-1 border border-border w-fit", className)}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={cn(
"relative flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors outline-none",
activeTab === tab.id ? "text-accent" : "text-muted hover:text-text/80"
)}
>
{activeTab === tab.id && (
<motion.div
layoutId="bubble"
className="absolute inset-0 bg-accent/10 border border-accent/20 rounded-lg shadow-sm"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
<span className="relative z-10 flex items-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
))}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { motion, HTMLMotionProps } from 'framer-motion'
export interface ButtonProps extends HTMLMotionProps<"button"> {
variant?: 'default' | 'outline' | 'ghost' | 'danger'
size?: 'default' | 'sm' | 'lg' | 'icon'
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<motion.button
ref={ref}
whileTap={{ scale: 0.98 }}
whileHover={{ scale: 1.02 }}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50',
{
'bg-accent/10 text-accent border border-accent/30 hover:bg-accent/20': variant === 'default',
'border border-border bg-transparent hover:bg-surface-2 text-text': variant === 'outline',
'hover:bg-surface-2 hover:text-text text-muted': variant === 'ghost',
'bg-destructive/10 text-destructive border border-destructive/30 hover:bg-destructive/20': variant === 'danger',
'h-9 px-4 py-2': size === 'default',
'h-8 rounded-md px-3 text-xs': size === 'sm',
'h-10 rounded-md px-8': size === 'lg',
'h-9 w-9': size === 'icon',
},
className
)}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button }
+69
View File
@@ -0,0 +1,69 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border border-border bg-surface/50 backdrop-blur-md text-text shadow-sm relative overflow-hidden group',
className
)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight text-accent', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted font-mono', className)}
{...props}
/>
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+71
View File
@@ -0,0 +1,71 @@
'use client'
import { useId } from 'react'
import { cn } from '@/lib/utils'
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
width?: number
height?: number
x?: number
y?: number
squares?: Array<[x: number, y: number]>
strokeDasharray?: string
className?: string
[key: string]: any
}
export function GridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = '0',
squares,
className,
...props
}: GridPatternProps) {
const id = useId()
return (
<svg
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 h-full w-full fill-white/[0.02] stroke-white/[0.02]',
className
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
{squares && (
<svg x={x} y={y} className="overflow-visible">
{squares.map(([x, y]) => (
<rect
strokeWidth="0"
key={`${x}-${y}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
/>
))}
</svg>
)}
</svg>
)
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-border bg-black/30 px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50 font-mono',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }
+13 -1
View File
@@ -55,8 +55,10 @@ pool.query(`
username TEXT NOT NULL,
password TEXT NOT NULL,
ssl BOOLEAN NOT NULL DEFAULT FALSE,
color TEXT
color TEXT,
db_type TEXT NOT NULL DEFAULT 'postgres'
);
ALTER TABLE config_databases ADD COLUMN IF NOT EXISTS db_type TEXT NOT NULL DEFAULT 'postgres';
CREATE TABLE IF NOT EXISTS config_services (
id TEXT PRIMARY KEY,
@@ -66,15 +68,25 @@ pool.query(`
description TEXT
);
CREATE TABLE IF NOT EXISTS config_docker_hosts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS backup_configs (
id TEXT PRIMARY KEY,
db_id TEXT NOT NULL,
schedule TEXT NOT NULL,
cloud_type TEXT NOT NULL,
credentials TEXT NOT NULL,
gdrive_folder_id TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
ALTER TABLE backup_configs ADD COLUMN IF NOT EXISTS gdrive_folder_id TEXT;
CREATE TABLE IF NOT EXISTS backup_logs (
id SERIAL PRIMARY KEY,
db_id TEXT NOT NULL,
+31 -30
View File
@@ -11,41 +11,39 @@ export interface Database {
password: string
ssl: boolean
color?: string
db_type?: 'postgres' | 'mysql'
}
export async function createPgDumpStream(db: Database) {
const env = {
...process.env,
PGPASSWORD: db.password,
}
const args = [
'-h', db.host,
'-p', db.port.toString(),
'-U', db.username,
'-d', db.database,
'-F', 'c', // custom format for smaller size / better restore, or 'p' for plain sql. Let's use 'p' for plain text so they can see it.
'--no-owner',
]
// if we want plain text:
args[args.indexOf('c')] = 'p'
const child = spawn('pg_dump', args, { env })
return child
}
export async function createPgDumpBuffer(db: Database): Promise<Buffer> {
export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn('pg_dump', [
let command = ''
let args: string[] = []
let env = { ...process.env }
if (db.db_type === 'mysql') {
command = 'mysqldump'
args = [
'-h', db.host,
'-P', db.port.toString(),
'-u', db.username,
`--password=${db.password}`,
'--no-tablespaces',
db.database
]
} else {
command = 'pg_dump'
args = [
'-h', db.host,
'-p', db.port.toString(),
'-U', db.username,
'-d', db.database,
'-F', 'p',
'--no-owner',
], {
env: { ...process.env, PGPASSWORD: db.password },
})
]
env.PGPASSWORD = db.password
}
const child = spawn(command, args, { env })
const chunks: Buffer[] = []
let errorOutput = ''
@@ -57,12 +55,12 @@ export async function createPgDumpBuffer(db: Database): Promise<Buffer> {
if (code === 0) {
resolve(Buffer.concat(chunks))
} else {
reject(new Error(`pg_dump exited with code ${code}: ${errorOutput}`))
reject(new Error(`${command} exited with code ${code}: ${errorOutput}`))
}
})
child.on('error', (err) => {
reject(new Error(`Failed to start pg_dump: ${err.message}`))
reject(new Error(`Failed to start ${command}: ${err.message}`))
})
})
}
@@ -90,7 +88,7 @@ export async function uploadToDropbox(token: string, filename: string, content:
return await response.json()
}
export async function uploadToGoogleDrive(serviceAccountJsonStr: string, filename: string, content: Buffer) {
export async function uploadToGoogleDrive(serviceAccountJsonStr: string, filename: string, content: Buffer, folderId?: string) {
const credentials = JSON.parse(serviceAccountJsonStr)
// 1. Get Access Token using jose
@@ -125,10 +123,13 @@ export async function uploadToGoogleDrive(serviceAccountJsonStr: string, filenam
const delimiter = `\r\n--${boundary}\r\n`
const close_delim = `\r\n--${boundary}--`
const metadata = {
const metadata: any = {
name: filename,
mimeType: 'application/sql'
}
if (folderId) {
metadata.parents = [folderId]
}
const multipartRequestBody = Buffer.concat([
Buffer.from(delimiter + 'Content-Type: application/json\r\n\r\n' + JSON.stringify(metadata) + '\r\n'),
+26 -8
View File
@@ -21,6 +21,7 @@ export interface Database {
password: string
ssl: boolean
color: string
db_type: 'postgres' | 'mysql'
}
export interface Service {
@@ -31,10 +32,17 @@ export interface Service {
description: string
}
export interface DockerHost {
id: string
name: string
url: string
}
export interface Config {
sites: Site[]
databases: Database[]
services: Service[]
docker_hosts: DockerHost[]
}
export function generateId(): string {
@@ -54,11 +62,14 @@ export async function readConfig(): Promise<Config> {
await pool.query(`INSERT INTO config_sites (id, name, url, interval_min) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, [site.id, site.name, site.url, site.interval_min || 5])
}
for (const db of oldConfig.databases || []) {
await pool.query(`INSERT INTO config_databases (id, name, host, port, database, username, password, ssl, color) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT DO NOTHING`, [db.id, db.name, db.host, db.port, db.database, db.username, db.password, db.ssl || false, db.color])
await pool.query(`INSERT INTO config_databases (id, name, host, port, database, username, password, ssl, color, db_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT DO NOTHING`, [db.id, db.name, db.host, db.port, db.database, db.username, db.password, db.ssl || false, db.color, db.db_type || 'postgres'])
}
for (const svc of oldConfig.services || []) {
await pool.query(`INSERT INTO config_services (id, name, url, icon, description) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING`, [svc.id, svc.name, svc.url, svc.icon, svc.description])
}
for (const host of oldConfig.docker_hosts || []) {
await pool.query(`INSERT INTO config_docker_hosts (id, name, url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [host.id, host.name, host.url])
}
// Dosyanın uzantısını değiştirerek migration'ın bittiğini işaretle
fs.renameSync(CONFIG_PATH, CONFIG_PATH + '.bak')
@@ -69,43 +80,50 @@ export async function readConfig(): Promise<Config> {
}
// DB'den oku
const [sitesRes, dbsRes, svcsRes] = await Promise.all([
const [sitesRes, dbsRes, svcsRes, dockerRes] = await Promise.all([
pool.query(`SELECT * FROM config_sites`),
pool.query(`SELECT * FROM config_databases`),
pool.query(`SELECT * FROM config_services`),
pool.query(`SELECT * FROM config_docker_hosts`),
])
return {
sites: sitesRes.rows as Site[],
databases: dbsRes.rows as Database[],
services: svcsRes.rows as Service[]
services: svcsRes.rows as Service[],
docker_hosts: dockerRes.rows as DockerHost[]
}
}
export async function addConfigItem(type: 'site' | 'database' | 'service', item: any): Promise<any> {
export async function addConfigItem(type: 'site' | 'database' | 'service' | 'docker', item: any): Promise<any> {
const newItem = { ...item, id: generateId() }
if (type === 'site') {
await pool.query(`INSERT INTO config_sites (id, name, url, interval_min) VALUES ($1, $2, $3, $4)`, [newItem.id, newItem.name, newItem.url, newItem.interval_min || 5])
} else if (type === 'database') {
await pool.query(`INSERT INTO config_databases (id, name, host, port, database, username, password, ssl, color) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [newItem.id, newItem.name, newItem.host, newItem.port, newItem.database, newItem.username, newItem.password, newItem.ssl || false, newItem.color])
await pool.query(`INSERT INTO config_databases (id, name, host, port, database, username, password, ssl, color, db_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [newItem.id, newItem.name, newItem.host, newItem.port, newItem.database, newItem.username, newItem.password, newItem.ssl || false, newItem.color, newItem.db_type || 'postgres'])
} else if (type === 'service') {
await pool.query(`INSERT INTO config_services (id, name, url, icon, description) VALUES ($1, $2, $3, $4, $5)`, [newItem.id, newItem.name, newItem.url, newItem.icon, newItem.description])
} else if (type === 'docker') {
await pool.query(`INSERT INTO config_docker_hosts (id, name, url) VALUES ($1, $2, $3)`, [newItem.id, newItem.name, newItem.url])
}
return newItem
}
export async function updateConfigItem(type: 'site' | 'database' | 'service', id: string, item: any): Promise<void> {
export async function updateConfigItem(type: 'site' | 'database' | 'service' | 'docker', id: string, item: any): Promise<void> {
if (type === 'site') {
await pool.query(`UPDATE config_sites SET name=$1, url=$2, interval_min=$3 WHERE id=$4`, [item.name, item.url, item.interval_min || 5, id])
} else if (type === 'database') {
await pool.query(`UPDATE config_databases SET name=$1, host=$2, port=$3, database=$4, username=$5, password=$6, ssl=$7, color=$8 WHERE id=$9`, [item.name, item.host, item.port, item.database, item.username, item.password, item.ssl || false, item.color, id])
await pool.query(`UPDATE config_databases SET name=$1, host=$2, port=$3, database=$4, username=$5, password=$6, ssl=$7, color=$8, db_type=$9 WHERE id=$10`, [item.name, item.host, item.port, item.database, item.username, item.password, item.ssl || false, item.color, item.db_type || 'postgres', id])
} else if (type === 'service') {
await pool.query(`UPDATE config_services SET name=$1, url=$2, icon=$3, description=$4 WHERE id=$5`, [item.name, item.url, item.icon, item.description, id])
} else if (type === 'docker') {
await pool.query(`UPDATE config_docker_hosts SET name=$1, url=$2 WHERE id=$3`, [item.name, item.url, id])
}
}
export async function deleteConfigItem(type: 'site' | 'database' | 'service', id: string): Promise<void> {
export async function deleteConfigItem(type: 'site' | 'database' | 'service' | 'docker', id: string): Promise<void> {
if (type === 'site') await pool.query(`DELETE FROM config_sites WHERE id=$1`, [id])
else if (type === 'database') await pool.query(`DELETE FROM config_databases WHERE id=$1`, [id])
else if (type === 'service') await pool.query(`DELETE FROM config_services WHERE id=$1`, [id])
else if (type === 'docker') await pool.query(`DELETE FROM config_docker_hosts WHERE id=$1`, [id])
}
+4 -13
View File
@@ -1,24 +1,15 @@
import * as cron from 'node-cron'
import pool from './appDb'
import { readConfig } from './config'
import { createPgDumpBuffer, uploadToDropbox, uploadToGoogleDrive } from './backup'
import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDrive } from './backup'
export function startBackupCron() {
console.log('[AutoBackup] Arka plan cron servisi başlatıldı.')
// Her dakika cron kurallarını ve görevleri çalıştıracak mantık
// Aslında node-cron'u her backup_config için ayrı cron olarak register etmek daha doğru.
// Ancak dinamik eklendiğinde/silindiğinde cron'ları güncellemek gerekir.
// Basitlik için her dakika DB'den konfigürasyonları okuyup, node-cron syntax'ının "match" edip etmediğine bakacağız,
// ya da her eklenen config için memory'de bir cron job tutacağız.
// En kolayı, veritabanını saatte bir kontrol edip zamanlamaları işletmek, ya da memory'de cron scheduler tutmaktır.
}
// memory map of active tasks
const activeTasks = new Map<string, cron.ScheduledTask>()
export async function reloadBackupCrons() {
// Clear existing
for (const [id, task] of activeTasks.entries()) {
task.stop()
}
@@ -30,13 +21,13 @@ export async function reloadBackupCrons() {
for (const row of rows) {
const db = config.databases.find(d => d.id === row.db_id)
if (!db) continue // Veritabanı silinmiş
if (!db) continue
const task = cron.schedule(row.schedule, async () => {
try {
console.log(`[Backup] ${db.name} (${row.cloud_type}) başlatılıyor...`)
const buffer = await createPgDumpBuffer(db)
const buffer = await createDbDumpBuffer(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql`
let message = 'Success'
@@ -45,7 +36,7 @@ export async function reloadBackupCrons() {
if (row.cloud_type === 'dropbox') {
await uploadToDropbox(row.credentials, filename, buffer)
} else if (row.cloud_type === 'gdrive') {
await uploadToGoogleDrive(row.credentials, filename, buffer)
await uploadToGoogleDrive(row.credentials, filename, buffer, row.gdrive_folder_id)
}
} catch (uploadErr: any) {
console.error(`[Backup] Upload hatası:`, uploadErr)
+97 -11
View File
@@ -1,41 +1,89 @@
import { Pool } from 'pg'
import { Pool as PgPool } from 'pg'
import mysql from 'mysql2/promise'
import { Database } from './config'
const pools = new Map<string, Pool>()
const pgPools = new Map<string, PgPool>()
const mysqlPools = new Map<string, mysql.Pool>()
function getPool(db: Database): Pool {
if (pools.has(db.id)) return pools.get(db.id)!
const pool = new Pool({
function getPgPool(db: Database): PgPool {
if (pgPools.has(db.id)) return pgPools.get(db.id)!
const pool = new PgPool({
host: db.host, port: db.port, database: db.database,
user: db.username, password: db.password,
ssl: db.ssl ? { rejectUnauthorized: false } : false,
max: 3, connectionTimeoutMillis: 8000,
})
pools.set(db.id, pool)
pgPools.set(db.id, pool)
return pool
}
function getMysqlPool(db: Database): mysql.Pool {
if (mysqlPools.has(db.id)) return mysqlPools.get(db.id)!
const pool = mysql.createPool({
host: db.host, port: db.port, database: db.database,
user: db.username, password: db.password,
ssl: db.ssl ? { rejectUnauthorized: false } : undefined,
connectionLimit: 3, connectTimeout: 8000,
})
mysqlPools.set(db.id, pool)
return pool
}
export async function testDb(db: Database) {
const start = Date.now()
try {
const pool = db.id === '__test__' ? new Pool({
if (db.db_type === 'mysql') {
const conn = await mysql.createConnection({
host: db.host, port: db.port, database: db.database,
user: db.username, password: db.password,
ssl: db.ssl ? { rejectUnauthorized: false } : undefined,
connectTimeout: 5000,
})
const [rows] = await conn.query('SELECT VERSION() as version')
await conn.end()
return { ok: true, ms: Date.now() - start, version: (rows as any)[0].version }
} else {
const pool = db.id === '__test__' ? new PgPool({
host: db.host, port: db.port, database: db.database,
user: db.username, password: db.password,
ssl: db.ssl ? { rejectUnauthorized: false } : false,
max: 1, connectionTimeoutMillis: 5000,
}) : getPool(db)
}) : getPgPool(db)
const client = await pool.connect()
const res = await client.query('SELECT version()')
client.release()
if (db.id === '__test__') await pool.end()
return { ok: true, ms: Date.now() - start, version: (res.rows[0].version as string).split(' ').slice(0, 2).join(' ') }
}
} catch (e: unknown) {
return { ok: false, ms: Date.now() - start, error: e instanceof Error ? e.message : 'Hata' }
}
}
export async function getDbStats(db: Database) {
const pool = getPool(db)
if (db.db_type === 'mysql') {
const pool = getMysqlPool(db)
const conn = await pool.getConnection()
try {
const [[sizeRes], [connsRes], [tablesRes]] = await Promise.all([
conn.query(`SELECT SUM(data_length + index_length) AS size FROM information_schema.TABLES WHERE table_schema = ?`, [db.database]),
conn.query(`SHOW STATUS WHERE \`variable_name\` = 'Threads_connected'`),
conn.query(`SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'`, [db.database]),
])
const sizeBytes = (sizeRes as any)[0].size || 0
let sizeStr = sizeBytes + ' bytes'
if (sizeBytes > 1024 * 1024) sizeStr = (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB'
else if (sizeBytes > 1024) sizeStr = (sizeBytes / 1024).toFixed(2) + ' KB'
return {
size: sizeStr,
active_connections: (connsRes as any)[0].Value,
table_count: (tablesRes as any)[0].cnt,
}
} finally { conn.release() }
} else {
const pool = getPgPool(db)
const client = await pool.connect()
try {
const [size, conns, tables] = await Promise.all([
@@ -49,10 +97,36 @@ export async function getDbStats(db: Database) {
table_count: tables.rows[0].cnt,
}
} finally { client.release() }
}
}
export async function getTables(db: Database) {
const pool = getPool(db)
if (db.db_type === 'mysql') {
const pool = getMysqlPool(db)
const conn = await pool.getConnection()
try {
const [rows] = await conn.query(`
SELECT table_name, table_rows AS row_count,
(data_length + index_length) AS size_bytes
FROM information_schema.tables
WHERE table_schema = ? AND table_type = 'BASE TABLE'
ORDER BY (data_length + index_length) DESC
`, [db.database])
return (rows as any[]).map(r => {
let sizeStr = r.size_bytes + ' bytes'
if (r.size_bytes > 1024 * 1024) sizeStr = (r.size_bytes / (1024 * 1024)).toFixed(2) + ' MB'
else if (r.size_bytes > 1024) sizeStr = (r.size_bytes / 1024).toFixed(2) + ' KB'
return {
table_name: r.table_name,
row_count: r.row_count || 0,
size: sizeStr
}
})
} finally { conn.release() }
} else {
const pool = getPgPool(db)
const client = await pool.connect()
try {
const res = await client.query(`
@@ -66,6 +140,7 @@ export async function getTables(db: Database) {
`)
return res.rows
} finally { client.release() }
}
}
export async function runQuery(db: Database, sql: string) {
@@ -74,7 +149,17 @@ export async function runQuery(db: Database, sql: string) {
if (blocked.some(k => trimmed.startsWith(k)))
return { rows: [], fields: [], error: 'Sadece SELECT sorguları çalıştırılabilir.' }
const pool = getPool(db)
if (db.db_type === 'mysql') {
const pool = getMysqlPool(db)
const conn = await pool.getConnection()
try {
const [rows, fields] = await conn.query(sql)
return { rows: rows as any[], fields: (fields as any[]).map(f => f.name), error: null }
} catch (e: unknown) {
return { rows: [], fields: [], error: e instanceof Error ? e.message : 'Sorgu hatası' }
} finally { conn.release() }
} else {
const pool = getPgPool(db)
const client = await pool.connect()
try {
const res = await client.query(sql)
@@ -82,4 +167,5 @@ export async function runQuery(db: Database, sql: string) {
} catch (e: unknown) {
return { rows: [], fields: [], error: e instanceof Error ? e.message : 'Sorgu hatası' }
} finally { client.release() }
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}