feat: add docker monitoring and ui fixes
This commit is contained in:
Generated
+191
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET(req: Request) {
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { db_id, schedule, cloud_type, credentials } = body
|
||||
const { db_id, schedule, cloud_type, credentials, gdrive_folder_id } = body
|
||||
|
||||
if (!db_id || !schedule || !cloud_type || !credentials) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
|
||||
@@ -37,13 +37,13 @@ export async function POST(req: Request) {
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await pool.query(
|
||||
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3 WHERE db_id=$4`,
|
||||
[schedule, cloud_type, credentials, db_id]
|
||||
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4 WHERE db_id=$5`,
|
||||
[schedule, cloud_type, credentials, gdrive_folder_id || null, db_id]
|
||||
)
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials) VALUES ($1, $2, $3, $4, $5)`,
|
||||
[generateId(), db_id, schedule, cloud_type, credentials]
|
||||
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials, gdrive_folder_id) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[generateId(), db_id, schedule, cloud_type, credentials, gdrive_folder_id || null]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readConfig } from '@/lib/config'
|
||||
import { createPgDumpStream } from '@/lib/backup'
|
||||
import { createDbDumpBuffer } from '@/lib/backup'
|
||||
import pool from '@/lib/appDb'
|
||||
|
||||
// GET /api/db/backup?dbId=...
|
||||
export async function GET(req: Request) {
|
||||
@@ -13,35 +14,20 @@ export async function GET(req: Request) {
|
||||
if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
|
||||
|
||||
try {
|
||||
const child = await createPgDumpStream(db)
|
||||
const buffer = await createDbDumpBuffer(db)
|
||||
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${db.name}_${dateStr}.sql`
|
||||
|
||||
// Stream the output of pg_dump to the response
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
child.stdout.on('data', (chunk) => controller.enqueue(chunk))
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
controller.close()
|
||||
} else {
|
||||
controller.error(new Error(`pg_dump exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
child.on('error', (err) => controller.error(err))
|
||||
},
|
||||
cancel() {
|
||||
child.kill()
|
||||
}
|
||||
})
|
||||
await pool.query(`INSERT INTO backup_logs (db_id, status, error, size, target) VALUES ($1, $2, $3, $4, $5)`, [dbId, 'success', null, buffer.length, 'manuel indirme'])
|
||||
|
||||
return new NextResponse(stream, {
|
||||
return new NextResponse(buffer.toString('utf-8'), {
|
||||
headers: {
|
||||
'Content-Type': 'application/sql',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
await pool.query(`INSERT INTO backup_logs (db_id, status, error, size, target) VALUES ($1, $2, $3, $4, $5)`, [dbId, 'error', e.message, 0, 'manuel indirme'])
|
||||
return NextResponse.json({ error: e.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readConfig } from '@/lib/config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const hostId = searchParams.get('hostId')
|
||||
|
||||
if (!hostId) {
|
||||
return NextResponse.json({ error: 'hostId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await readConfig()
|
||||
const host = config.docker_hosts?.find(h => h.id === hostId)
|
||||
|
||||
if (!host) {
|
||||
return NextResponse.json({ error: 'Docker host not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let baseUrl = host.url
|
||||
if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1)
|
||||
|
||||
// Docker Engine API: list all containers
|
||||
const res = await fetch(`${baseUrl}/containers/json?all=1`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
cache: 'no-store'
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
return NextResponse.json({ error: `Docker API Error: ${res.statusText}`, details: text }, { status: res.status })
|
||||
}
|
||||
|
||||
const containers = await res.json()
|
||||
return NextResponse.json(containers)
|
||||
} catch (error: any) {
|
||||
console.error('Docker API Error:', error)
|
||||
return NextResponse.json({ error: 'Bağlantı hatası', details: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs'
|
||||
import { Edit2, Trash2, Plus, Code, Globe, CalendarDays } from 'lucide-react'
|
||||
|
||||
type Stats = {
|
||||
total: number
|
||||
@@ -14,29 +19,29 @@ type Stats = {
|
||||
}
|
||||
|
||||
const DAYS_OPTIONS = [
|
||||
{ label: '1g', value: 1 },
|
||||
{ label: '7g', value: 7 },
|
||||
{ label: '30g', value: 30 },
|
||||
{ label: '90g', value: 90 },
|
||||
{ id: '1', label: '1G' },
|
||||
{ id: '7', label: '7G' },
|
||||
{ id: '30', label: '30G' },
|
||||
{ id: '90', label: '90G' },
|
||||
]
|
||||
|
||||
function Bar({ label, value, max, color = 'var(--accent)' }: { label: string; value: number; max: number; color?: string }) {
|
||||
function Bar({ label, value, max, color = 'var(--color-accent)' }: { label: string; value: number; max: number; color?: string }) {
|
||||
const pct = max > 0 ? (value / max) * 100 : 0
|
||||
return (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, marginRight: 8 }}>{label}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', flexShrink: 0 }}>{value.toLocaleString()}</span>
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between mb-1.5">
|
||||
<span className="text-xs text-text font-mono truncate flex-1 mr-2">{label}</span>
|
||||
<span className="text-xs text-muted font-mono shrink-0">{value.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: 'rgba(255,255,255,.06)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 2, transition: 'width .5s ease' }} />
|
||||
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-500 ease-out" style={{ width: `${pct}%`, background: color }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniChart({ data, days }: { data: { day: string; views: number; visitors: number }[]; days: number }) {
|
||||
if (!data.length) return <div style={{ height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Henüz veri yok</div>
|
||||
if (!data.length) return <div className="h-20 flex items-center justify-center text-muted text-xs font-mono">Henüz veri yok</div>
|
||||
|
||||
// Tüm günleri doldur
|
||||
const filled: { day: string; views: number; visitors: number }[] = []
|
||||
@@ -52,11 +57,19 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
|
||||
const maxViews = Math.max(...filled.map(d => d.views), 1)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 80, padding: '0 4px' }}>
|
||||
<div className="flex items-end gap-1 h-20 px-1">
|
||||
{filled.map((d, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, height: '100%', justifyContent: 'flex-end' }}
|
||||
title={`${d.day}: ${d.views} görüntüleme, ${d.visitors} ziyaretçi`}>
|
||||
<div style={{ width: '100%', background: 'var(--accent)', borderRadius: '2px 2px 0 0', opacity: .85, height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 4 : 0)}%`, transition: 'height .3s ease', minHeight: d.views > 0 ? 2 : 0 }} />
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-center gap-[1px] h-full justify-end"
|
||||
>
|
||||
<div
|
||||
className="w-full bg-accent/80 rounded-t-sm transition-all duration-300 ease-out"
|
||||
style={{
|
||||
height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 4 : 0)}%`,
|
||||
minHeight: d.views > 0 ? 2 : 0
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -66,7 +79,7 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
|
||||
export default function AnalyticsPage() {
|
||||
const [domains, setDomains] = useState<string[]>([])
|
||||
const [selectedDomain, setSelectedDomain] = useState<string | null>(null)
|
||||
const [days, setDays] = useState(30)
|
||||
const [days, setDays] = useState('30')
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSnippet, setShowSnippet] = useState(false)
|
||||
@@ -118,7 +131,6 @@ export default function AnalyticsPage() {
|
||||
})
|
||||
setSelectedDomain(newName.trim())
|
||||
|
||||
// Refresh domains after brief delay for state to sync
|
||||
setTimeout(loadDomains, 100)
|
||||
}
|
||||
|
||||
@@ -137,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'ı 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' }}><head></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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, X, Box, ShieldAlert, CheckCircle2, Play, Square, RotateCw } from 'lucide-react'
|
||||
|
||||
type DockerHost = { id: string; name: string; url: string }
|
||||
type Container = {
|
||||
Id: string
|
||||
Names: string[]
|
||||
Image: string
|
||||
State: string
|
||||
Status: string
|
||||
Ports: { PrivatePort: number; PublicPort?: number; Type: string }[]
|
||||
}
|
||||
|
||||
export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[] }) {
|
||||
const [hosts, setHosts] = useState<DockerHost[]>(initialHosts)
|
||||
const [selected, setSelected] = useState<DockerHost | null>(null)
|
||||
const [containers, setContainers] = useState<Container[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
|
||||
const loadHosts = useCallback(async () => {
|
||||
const r = await fetch('/api/config')
|
||||
const cfg = await r.json()
|
||||
if (cfg.docker_hosts) setHosts(cfg.docker_hosts)
|
||||
}, [])
|
||||
|
||||
const select = async (host: DockerHost) => {
|
||||
setSelected(host)
|
||||
setContainers([])
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await fetch(`/api/docker?hostId=${host.id}`)
|
||||
const data = await r.json()
|
||||
if (r.ok) {
|
||||
setContainers(data)
|
||||
} else {
|
||||
setError(data.error || 'Bağlantı hatası')
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const deleteHost = async (id: string) => {
|
||||
if (!confirm('Docker bağlantısını sil?')) return
|
||||
await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', id }) })
|
||||
if (selected?.id === id) setSelected(null)
|
||||
loadHosts()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* LEFT */}
|
||||
<div className="w-64 bg-surface border-r border-border flex flex-col shrink-0">
|
||||
<div className="p-4 border-b border-border/50 flex justify-between items-center">
|
||||
<span className="text-xs font-mono text-muted uppercase tracking-widest">Docker Hosts</span>
|
||||
<Button variant="default" size="sm" onClick={() => setShowAdd(true)} className="h-7 text-xs">
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Ekle
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 custom-scrollbar">
|
||||
{hosts.length === 0
|
||||
? <div className="p-5 text-muted text-xs font-mono text-center">Henüz Host yok</div>
|
||||
: hosts.map(host => (
|
||||
<div
|
||||
key={host.id}
|
||||
onClick={() => select(host)}
|
||||
className={`p-3 rounded-xl cursor-pointer transition-all border ${
|
||||
selected?.id === host.id
|
||||
? 'bg-accent/5 border-accent/20'
|
||||
: 'border-transparent hover:bg-surface-2 hover:border-border/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Box className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-semibold flex-1 truncate">{host.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); deleteHost(host.id) }} className="text-muted hover:text-destructive transition-colors shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[10px] text-muted font-mono pl-6">
|
||||
<span className="truncate mr-2">{host.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT */}
|
||||
<div className="flex-1 overflow-auto custom-scrollbar relative">
|
||||
{!selected
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted font-mono text-sm opacity-50">
|
||||
<Box className="w-5 h-5 mr-3" />
|
||||
Sol menüden bir Docker sunucusu seçin
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="p-8 max-w-6xl mx-auto fade-up">
|
||||
<div className="mb-8 flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Box className="w-6 h-6 text-accent" />
|
||||
<h2 className="text-2xl font-extrabold tracking-tight">{selected.name}</h2>
|
||||
</div>
|
||||
<div className="text-sm text-muted font-mono bg-surface-2 w-fit px-3 py-1 rounded-md border border-border/50">
|
||||
{selected.url}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => select(selected)} disabled={loading}>
|
||||
<RotateCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Yenile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 text-destructive font-mono text-sm bg-destructive/5 flex items-center gap-2 border border-destructive/20 rounded-xl mb-6">
|
||||
<ShieldAlert className="w-4 h-4"/> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Containers */}
|
||||
{!error && (
|
||||
<Card className="overflow-hidden">
|
||||
{loading && containers.length === 0
|
||||
? <div className="p-6 text-muted font-mono text-sm text-center animate-pulse">Konteynerler yükleniyor...</div>
|
||||
: containers.length === 0
|
||||
? <div className="p-6 text-muted font-mono text-sm text-center">Konteyner bulunamadı.</div>
|
||||
: <div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{['İsim', 'İmaj', 'Durum', 'Portlar'].map(h => (
|
||||
<th key={h} className="px-6 py-3 text-[10px] font-mono text-muted uppercase tracking-wider border-b border-border bg-black/20">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{containers.map((c, i) => {
|
||||
const name = c.Names?.[0]?.replace(/^\//, '') || 'Bilinmiyor'
|
||||
const isRunning = c.State === 'running'
|
||||
return (
|
||||
<tr
|
||||
key={c.Id}
|
||||
className="group hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className={`px-6 py-4 text-sm font-semibold text-accent ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
|
||||
{name}
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-xs font-mono text-muted max-w-[200px] truncate ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
|
||||
{c.Image}
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-xs font-mono ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isRunning ? 'bg-success shadow-[0_0_8px_var(--color-success)]' : 'bg-muted'}`} />
|
||||
<span className={isRunning ? 'text-success' : 'text-muted'}>{c.Status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-[10px] font-mono text-muted ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{c.Ports?.map((p, idx) => (
|
||||
<span key={idx} className="bg-surface-2 px-1.5 py-0.5 rounded border border-border/50">
|
||||
{p.PublicPort ? `${p.PublicPort}->${p.PrivatePort}` : p.PrivatePort}/{p.Type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAdd && <AddHostModal onClose={() => setShowAdd(false)} onAdded={() => { loadHosts(); setShowAdd(false) }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddHostModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
|
||||
const [f, setF] = useState({ name: '', url: 'http://' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', item: f }) })
|
||||
if (res.ok) { onAdded() } else { setError((await res.json()).error ?? 'Hata'); setLoading(false) }
|
||||
} catch (e) {
|
||||
setError('Bağlantı eklenemedi.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<Card className="w-full max-w-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
|
||||
<CardHeader className="pb-4 border-b border-border/50">
|
||||
<CardTitle>Yeni Docker Host</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Sunucu Adı</label>
|
||||
<Input placeholder="Remote VPS" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Docker API URL</label>
|
||||
<Input placeholder="http://192.168.1.10:2375" value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} />
|
||||
<p className="text-[10px] text-muted font-mono mt-1">Docker Daemon'ın TCP üzerinden erişilebilir olduğundan emin olun.</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-destructive text-xs font-mono bg-destructive/10 p-2 rounded-md border border-destructive/20 mt-2 flex items-center gap-2"><ShieldAlert className="w-3.5 h-3.5"/>{error}</div>}
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-border/50 mt-6">
|
||||
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button>
|
||||
<Button onClick={submit} disabled={loading || !f.name || !f.url} className="flex-1">
|
||||
{loading ? 'Ekleniyor...' : 'Ekle'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { readConfig } from '@/lib/config'
|
||||
import ClientPage from './ClientPage'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function DockerPage() {
|
||||
const config = await readConfig()
|
||||
return <ClientPage initialHosts={config.docker_hosts || []} />
|
||||
}
|
||||
+27
-18
@@ -1,5 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: #0d0f14;
|
||||
--color-surface: #13161f;
|
||||
--color-surface-2: #1a1f2e;
|
||||
--color-border: rgba(255, 255, 255, 0.07);
|
||||
--color-accent: #00e5ff;
|
||||
--color-accent-transparent: rgba(0, 229, 255, 0.1);
|
||||
--color-success: #00e676;
|
||||
--color-warning: #ffab00;
|
||||
--color-destructive: #ff3d71;
|
||||
--color-text: #e2e8f0;
|
||||
--color-muted: #8b95a5;
|
||||
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-fade-up: fadeUp 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0d0f14;
|
||||
--surface: #13161f;
|
||||
@@ -11,34 +28,26 @@
|
||||
--red: #ff3d71;
|
||||
--purple: #7b61ff;
|
||||
--text: #e2e8f0;
|
||||
--muted: #4a5270;
|
||||
--muted: #8b95a5;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,229,255,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,229,255,0.02) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
/* Base custom scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #2a3050; border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
|
||||
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
@keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
|
||||
.fade-up { animation: fadeUp .25s ease forwards; }
|
||||
@keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
|
||||
.fade-up { animation: fadeUp .3s ease forwards; }
|
||||
|
||||
+62
-17
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([
|
||||
@@ -50,9 +98,35 @@ export async function getDbStats(db: Database) {
|
||||
}
|
||||
} 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(`
|
||||
@@ -67,6 +141,7 @@ export async function getTables(db: Database) {
|
||||
return res.rows
|
||||
} finally { client.release() }
|
||||
}
|
||||
}
|
||||
|
||||
export async function runQuery(db: Database, sql: string) {
|
||||
const trimmed = sql.trim().toLowerCase()
|
||||
@@ -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)
|
||||
@@ -83,3 +168,4 @@ export async function runQuery(db: Database, sql: string) {
|
||||
return { rows: [], fields: [], error: e instanceof Error ? e.message : 'Sorgu hatası' }
|
||||
} finally { client.release() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user