feat: add docker monitoring and ui fixes

This commit is contained in:
mstfyldz
2026-06-03 00:58:54 +03:00
parent e5dc347a0b
commit 29d03604f9
24 changed files with 1682 additions and 553 deletions
View File
+191 -1
View File
@@ -10,13 +10,17 @@
"dependencies": { "dependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"jose": "^6.2.3", "jose": "^6.2.3",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"mysql2": "^3.22.4",
"next": "16.2.6", "next": "16.2.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.21.0", "pg": "^8.21.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -2499,6 +2503,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/axe-core": {
"version": "4.11.4", "version": "4.11.4",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",
@@ -2699,6 +2712,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2877,6 +2899,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3690,6 +3721,33 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3731,6 +3789,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -3995,6 +4062,22 @@
"hermes-estree": "0.25.1" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4317,6 +4400,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4924,6 +5013,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4947,6 +5042,21 @@
"yallist": "^3.0.2" "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": { "node_modules/lucide-react": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
@@ -5023,6 +5133,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5030,6 +5155,40 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -5866,6 +6025,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6106,6 +6271,21 @@
"node": ">= 10.x" "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": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6312,6 +6492,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tailwindcss": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
+5 -1
View File
@@ -11,13 +11,17 @@
"dependencies": { "dependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"jose": "^6.2.3", "jose": "^6.2.3",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"mysql2": "^3.22.4",
"next": "16.2.6", "next": "16.2.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.21.0", "pg": "^8.21.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+5 -5
View File
@@ -26,7 +26,7 @@ export async function GET(req: Request) {
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const body = await req.json() 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) { if (!db_id || !schedule || !cloud_type || !credentials) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 }) return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
@@ -37,13 +37,13 @@ export async function POST(req: Request) {
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
await pool.query( await pool.query(
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3 WHERE db_id=$4`, `UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3, gdrive_folder_id=$4 WHERE db_id=$5`,
[schedule, cloud_type, credentials, db_id] [schedule, cloud_type, credentials, gdrive_folder_id || null, db_id]
) )
} else { } else {
await pool.query( await pool.query(
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials) VALUES ($1, $2, $3, $4, $5)`, `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] [generateId(), db_id, schedule, cloud_type, credentials, gdrive_folder_id || null]
) )
} }
+6 -20
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { readConfig } from '@/lib/config' 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=... // GET /api/db/backup?dbId=...
export async function GET(req: Request) { 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 }) if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
try { try {
const child = await createPgDumpStream(db) const buffer = await createDbDumpBuffer(db)
const dateStr = new Date().toISOString().replace(/[:.]/g, '-') const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql` const filename = `${db.name}_${dateStr}.sql`
// Stream the output of pg_dump to the response 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'])
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()
}
})
return new NextResponse(stream, { return new NextResponse(buffer.toString('utf-8'), {
headers: { headers: {
'Content-Type': 'application/sql', 'Content-Type': 'application/sql',
'Content-Disposition': `attachment; filename="${filename}"` 'Content-Disposition': `attachment; filename="${filename}"`
} }
}) })
} catch (e: any) { } 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 }) return NextResponse.json({ error: e.message }, { status: 500 })
} }
} }
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { readConfig } from '@/lib/config'
export const dynamic = 'force-dynamic'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const hostId = searchParams.get('hostId')
if (!hostId) {
return NextResponse.json({ error: 'hostId is required' }, { status: 400 })
}
try {
const config = await readConfig()
const host = config.docker_hosts?.find(h => h.id === hostId)
if (!host) {
return NextResponse.json({ error: 'Docker host not found' }, { status: 404 })
}
let baseUrl = host.url
if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1)
// Docker Engine API: list all containers
const res = await fetch(`${baseUrl}/containers/json?all=1`, {
headers: { 'Accept': 'application/json' },
cache: 'no-store'
})
if (!res.ok) {
const text = await res.text()
return NextResponse.json({ error: `Docker API Error: ${res.statusText}`, details: text }, { status: res.status })
}
const containers = await res.json()
return NextResponse.json(containers)
} catch (error: any) {
console.error('Docker API Error:', error)
return NextResponse.json({ error: 'Bağlantı hatası', details: error.message }, { status: 500 })
}
}
+184 -133
View File
@@ -1,5 +1,10 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AnimatedTabs } from '@/components/ui/animated-tabs'
import { Edit2, Trash2, Plus, Code, Globe, CalendarDays } from 'lucide-react'
type Stats = { type Stats = {
total: number total: number
@@ -14,29 +19,29 @@ type Stats = {
} }
const DAYS_OPTIONS = [ const DAYS_OPTIONS = [
{ label: '1g', value: 1 }, { id: '1', label: '1G' },
{ label: '7g', value: 7 }, { id: '7', label: '7G' },
{ label: '30g', value: 30 }, { id: '30', label: '30G' },
{ label: '90g', value: 90 }, { 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 const pct = max > 0 ? (value / max) * 100 : 0
return ( return (
<div style={{ marginBottom: 10 }}> <div className="mb-3">
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> <div className="flex justify-between mb-1.5">
<span style={{ fontSize: 12, color: 'var(--text)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, marginRight: 8 }}>{label}</span> <span className="text-xs text-text font-mono truncate flex-1 mr-2">{label}</span>
<span style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', flexShrink: 0 }}>{value.toLocaleString()}</span> <span className="text-xs text-muted font-mono shrink-0">{value.toLocaleString()}</span>
</div> </div>
<div style={{ height: 4, background: 'rgba(255,255,255,.06)', borderRadius: 2, overflow: 'hidden' }}> <div className="h-1.5 bg-white/5 rounded-full overflow-hidden">
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 2, transition: 'width .5s ease' }} /> <div className="h-full rounded-full transition-all duration-500 ease-out" style={{ width: `${pct}%`, background: color }} />
</div> </div>
</div> </div>
) )
} }
function MiniChart({ data, days }: { data: { day: string; views: number; visitors: number }[]; days: number }) { function MiniChart({ data, days }: { data: { day: string; views: number; visitors: number }[]; days: number }) {
if (!data.length) return <div 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 // Tüm günleri doldur
const filled: { day: string; views: number; visitors: number }[] = [] 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) const maxViews = Math.max(...filled.map(d => d.views), 1)
return ( 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) => ( {filled.map((d, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, height: '100%', justifyContent: 'flex-end' }} <div
title={`${d.day}: ${d.views} görüntüleme, ${d.visitors} ziyaretçi`}> key={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 }} /> 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>
))} ))}
</div> </div>
@@ -66,7 +79,7 @@ function MiniChart({ data, days }: { data: { day: string; views: number; visitor
export default function AnalyticsPage() { export default function AnalyticsPage() {
const [domains, setDomains] = useState<string[]>([]) const [domains, setDomains] = useState<string[]>([])
const [selectedDomain, setSelectedDomain] = useState<string | null>(null) const [selectedDomain, setSelectedDomain] = useState<string | null>(null)
const [days, setDays] = useState(30) const [days, setDays] = useState('30')
const [stats, setStats] = useState<Stats | null>(null) const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showSnippet, setShowSnippet] = useState(false) const [showSnippet, setShowSnippet] = useState(false)
@@ -118,7 +131,6 @@ export default function AnalyticsPage() {
}) })
setSelectedDomain(newName.trim()) setSelectedDomain(newName.trim())
// Refresh domains after brief delay for state to sync
setTimeout(loadDomains, 100) setTimeout(loadDomains, 100)
} }
@@ -137,193 +149,232 @@ export default function AnalyticsPage() {
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>` <script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
return ( return (
<div style={{ padding: 28, maxWidth: 1100 }}> <div className="p-8 max-w-[1200px] mx-auto fade-up">
{/* Header */} {/* 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> <div>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Analytics</h1> <h1 className="text-2xl font-extrabold tracking-tight mb-1">Analytics</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cookie-free, self-hosted Plausible benzeri</p> <p className="text-xs text-muted font-mono tracking-wide">Cookie-free, self-hosted website tracking</p>
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="flex flex-wrap items-center gap-3">
{/* Domain Actions */}
{selectedDomain && ( {selectedDomain && (
<> <div className="flex items-center gap-2 bg-surface-2 p-1 border border-border/50 rounded-xl">
<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 variant="ghost" size="sm" onClick={handleRename} className="h-8 px-3 text-xs">
<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> <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' }}> <Button variant="outline" size="sm" onClick={() => setShowAddSite(true)} className="h-10">
+ Site Ekle <Plus className="w-4 h-4 mr-1.5" /> Site Ekle
</button> </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' }}> <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">
{'</>'} Snippet <Code className="w-4 h-4 mr-1.5" /> Snippet
</button> </Button>
{/* Days filter */}
<div style={{ display: 'flex', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 7, overflow: 'hidden' }}> {/* Days filter - using AnimatedTabs */}
{DAYS_OPTIONS.map(opt => ( <div className="ml-2">
<button key={opt.value} onClick={() => setDays(opt.value)} <AnimatedTabs
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)' }}> activeTab={days}
{opt.label} onChange={(id) => setDays(id)}
</button> tabs={DAYS_OPTIONS}
))} />
</div> </div>
</div> </div>
</div> </div>
{/* Domain tabs */} {/* Domain tabs */}
{domains.length > 0 ? ( {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 => ( {domains.map(d => (
<button key={d} onClick={() => setSelectedDomain(d)} <button
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 }}> 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} {d}
</button> </button>
))} ))}
</div> </div>
) : ( ) : (
<div style={{ background: 'var(--surface)', border: '1px dashed var(--border)', borderRadius: 12, padding: '40px', textAlign: 'center', marginBottom: 20 }}> <Card className="border-dashed border-2 bg-surface/30">
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>Henüz veri yok</div> <CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 16 }}>Sitelerine tracking snippet ekle, veriler burada görünecek.</div> <Globe className="w-12 h-12 text-muted/30 mb-4" />
<button onClick={() => setShowSnippet(true)} <h3 className="text-lg font-bold mb-2">Henüz veri yok</h3>
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' }}> <p className="text-sm text-muted mb-6">Sitelerine tracking snippet ekle, veriler burada görünecek.</p>
{'</>'} Snippet&apos;ı Göster <Button onClick={() => setShowSnippet(true)}>
</button> <Code className="w-4 h-4 mr-2" /> Snippet'ı Göster
</div> </Button>
</CardContent>
</Card>
)} )}
{stats && !loading && ( {stats && !loading && (
<> <div className="space-y-6 fade-up">
{/* Big numbers */} {/* 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: 'Toplam Görüntüleme', value: stats.total.toLocaleString(), color: 'text-accent' },
{ label: 'Tekil Ziyaretçi', value: stats.unique.toLocaleString(), color: 'var(--green)' }, { label: 'Tekil Ziyaretçi', value: stats.unique.toLocaleString(), color: 'text-success' },
].map(s => ( ].map(s => (
<div key={s.label} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '18px 20px' }}> <Card key={s.label} className="bg-surface/50 border-border/50">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>{s.label}</div> <CardContent className="p-6">
<div style={{ fontSize: 32, fontWeight: 800, color: s.color, letterSpacing: -1 }}>{s.value}</div> <div className="text-[10px] text-muted font-mono uppercase tracking-widest mb-2">{s.label}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>son {days} gün</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> </div>
</CardContent>
</Card>
))} ))}
</div> </div>
{/* Chart */} {/* Chart */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 20px', marginBottom: 20 }}> <Card className="bg-surface/50 border-border/50">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 12 }}>Günlük Görüntülemeler</div> <CardHeader className="pb-2">
<MiniChart data={stats.daily} days={days} /> <CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Günlük Görüntülemeler</CardTitle>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6 }}> </CardHeader>
<span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>{days} gün önce</span> <CardContent>
<span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>bugün</span> <MiniChart data={stats.daily} days={parseInt(days)} />
</div> <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> </div>
</CardContent>
</Card>
{/* 3-col breakdown */} {/* 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 */} {/* Top pages */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}> <Card className="bg-surface/50 border-border/50">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Sayfalar</div> <CardHeader className="pb-4">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Sayfalar</CardTitle>
</CardHeader>
<CardContent>
{stats.topPages.length === 0 {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} />) : stats.topPages.map(p => <Bar key={p.path} label={p.path || '/'} value={p.views} max={stats.topPages[0]?.views ?? 1} />)
} }
</div> </CardContent>
</Card>
{/* Referrers */} {/* Referrers */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}> <Card className="bg-surface/50 border-border/50">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Trafik Kaynağı</div> <CardHeader className="pb-4">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Trafik Kaynağı</CardTitle>
</CardHeader>
<CardContent>
{stats.topReferrers.length === 0 {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 => { : stats.topReferrers.map(r => {
let label = r.referrer let label = r.referrer
try { label = new URL(r.referrer).hostname } catch { /* ignore */ } 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 */} {/* Countries */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}> <Card className="bg-surface/50 border-border/50">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Ülkeler</div> <CardHeader className="pb-4">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">Ülkeler</CardTitle>
</CardHeader>
<CardContent>
{stats.byCountry.length === 0 {stats.byCountry.length === 0
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cloudflare proxy gerekli</div> ? <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(--yellow)" />) : 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> </div>
{/* Device / Browser / OS */} {/* 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: '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: '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 })) }, { title: 'İşletim Sistemi', data: stats.byOs.map(d => ({ label: d.os, value: d.n })) },
].map(section => ( ].map(section => (
<div key={section.title} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}> <Card key={section.title} className="bg-surface/50 border-border/50">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>{section.title}</div> <CardHeader className="pb-4">
<CardTitle className="text-[10px] text-muted font-mono uppercase tracking-widest">{section.title}</CardTitle>
</CardHeader>
<CardContent>
{section.data.length === 0 {section.data.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>
: 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)" />) : 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>
</> </div>
)} )}
{loading && ( {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 */} {/* Snippet modal */}
{showSnippet && ( {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 className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" 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()}> <Card className="w-full max-w-[560px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Tracking Snippet</div> <CardHeader>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}> <CardTitle>Tracking Snippet</CardTitle>
Sitendeki <code style={{ background: 'rgba(0,0,0,.3)', padding: '2px 6px', borderRadius: 4, fontFamily: 'monospace' }}>&lt;head&gt;</code> tagının içine ekle: <CardDescription>
</div> 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>
<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 }}> </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} {snippet}
</pre> </pre>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8, fontFamily: 'monospace' }}> <div className="space-y-2 text-xs text-muted font-mono bg-surface-2 p-3 rounded-lg border border-border/50">
<span style={{ color: 'var(--green)' }}>data-domain</span> hangi domain olduğunu belirtir, değiştirme <div><span className="text-success font-semibold">data-domain</span> — hangi domain olduğunu belirtir, değiştirme</div>
</div> <div><span className="text-success font-semibold">defer</span> — sayfayı yavaşlatmaz, arka planda yüklenir</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20, fontFamily: 'monospace' }}>
<span style={{ color: 'var(--green)' }}>defer</span> sayfayı yavaşlatmaz, arka planda yüklenir
</div> </div>
<div style={{ display: 'flex', gap: 10 }}> <div className="flex gap-3 pt-2">
<button <Button onClick={() => { navigator.clipboard.writeText(snippet); }} className="flex-1">
onClick={() => { navigator.clipboard.writeText(snippet); }}
style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
Kopyala Kopyala
</button> </Button>
<button onClick={() => setShowSnippet(false)} <Button variant="ghost" onClick={() => setShowSnippet(false)} className="flex-1">
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
Kapat Kapat
</button> </Button>
</div>
</div> </div>
</CardContent>
</Card>
</div> </div>
)} )}
{/* Add Site modal */} {/* Add Site modal */}
{showAddSite && ( {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 className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" 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()}> <Card className="w-full max-w-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Yeni Site Ekle</div> <CardHeader>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}> <CardTitle>Yeni Site Ekle</CardTitle>
Takip etmek istediğiniz alan adını girin (örn: example.com) <CardDescription>Takip etmek istediğiniz alan adını girin (örn: example.com)</CardDescription>
</div> </CardHeader>
<CardContent className="space-y-4">
<input <Input
style={{ width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 20 }}
placeholder="example.com" placeholder="example.com"
value={newDomain} value={newDomain}
onChange={e => setNewDomain(e.target.value)} onChange={e => setNewDomain(e.target.value)}
autoFocus
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter' && newDomain.trim()) { if (e.key === 'Enter' && newDomain.trim()) {
fetch('/api/analytics/stats', { fetch('/api/analytics/stats', {
@@ -340,8 +391,11 @@ export default function AnalyticsPage() {
}} }}
/> />
<div style={{ display: 'flex', gap: 10 }}> <div className="flex gap-3 pt-2">
<button <Button variant="ghost" onClick={() => setShowAddSite(false)} className="flex-1">
İptal
</Button>
<Button
disabled={!newDomain.trim()} disabled={!newDomain.trim()}
onClick={() => { onClick={() => {
fetch('/api/analytics/stats', { fetch('/api/analytics/stats', {
@@ -355,18 +409,15 @@ export default function AnalyticsPage() {
setSelectedDomain(newDomain.trim()); 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 Ekle
</button> </Button>
<button onClick={() => setShowAddSite(false)}
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
İptal
</button>
</div>
</div> </div>
</CardContent>
</Card>
</div> </div>
)} )}
</div> </div>
) )
} }
+309 -136
View File
@@ -1,7 +1,12 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' 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 Table = { table_name: string; row_count: number; size: string }
type QueryResult = { rows: Record<string, unknown>[]; fields: string[]; error: string | null } type QueryResult = { rows: Record<string, unknown>[]; fields: string[]; error: string | null }
@@ -50,25 +55,38 @@ export default function DatabasesPage() {
} }
return ( return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}> <div className="flex h-screen overflow-hidden bg-background">
{/* LEFT */} {/* LEFT */}
<div style={{ width: 240, background: 'var(--surface)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}> <div className="w-64 bg-surface border-r border-border flex flex-col shrink-0">
<div style={{ padding: '16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="p-4 border-b border-border/50 flex justify-between items-center">
<span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>Databases</span> <span className="text-xs font-mono text-muted uppercase tracking-widest">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> <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>
<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 {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 => ( : 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
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}> key={db.id}
<div style={{ width: 8, height: 8, borderRadius: '50%', background: db.color, flexShrink: 0 }} /> onClick={() => select(db)}
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{db.name}</span> className={`p-3 rounded-xl cursor-pointer transition-all border ${
<button onClick={e => { e.stopPropagation(); deleteDb(db.id) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer', flexShrink: 0 }}></button> 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>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', paddingLeft: 16, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div className="flex justify-between items-center text-[10px] text-muted font-mono pl-4">
{db.host}/{db.database} <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>
</div> </div>
))} ))}
@@ -76,90 +94,149 @@ export default function DatabasesPage() {
</div> </div>
{/* RIGHT */} {/* RIGHT */}
<div style={{ flex: 1, overflow: 'auto' }}> <div className="flex-1 overflow-auto custom-scrollbar relative">
{!selected {!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 className="p-8 max-w-6xl mx-auto fade-up">
<div style={{ marginBottom: 20 }}> <div className="mb-8">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}> <div className="flex items-center gap-3 mb-2">
<div style={{ width: 10, height: 10, borderRadius: '50%', background: selected.color }} /> <div className="w-3 h-3 rounded-full" style={{ background: selected.color, boxShadow: `0 0 10px ${selected.color}` }} />
<h2 style={{ fontSize: 18, fontWeight: 800 }}>{selected.name}</h2> <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>
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>{selected.username}@{selected.host}:{selected.port}/{selected.database}</div>
</div> </div>
{/* Stats row */} {/* Stats row */}
{stats && ( {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: 'Boyut', v: String(stats.size), icon: HardDrive },
{ l: 'Aktif Bağlantı', v: String(stats.active_connections) }, { l: 'Aktif Bağlantı', v: String(stats.active_connections), icon: ActivityIcon },
{ l: 'Tablo Sayısı', v: String(stats.table_count) }, { l: 'Tablo Sayısı', v: String(stats.table_count), icon: TableIcon },
].map(s => ( ].map(s => (
<div key={s.l} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}> <Card key={s.l} className="bg-surface/30">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }}>{s.l}</div> <CardContent className="p-5 flex items-start justify-between">
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--accent)' }}>{s.v}</div> <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> </div>
<s.icon className="w-5 h-5 text-accent/30" />
</CardContent>
</Card>
))} ))}
</div> </div>
)} )}
{/* Tabs */} {/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 16, gap: 4 }}> <div className="mb-6">
{(['tables', 'query', 'stats', 'backups'] as const).map(t => ( <AnimatedTabs
<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 }}> activeTab={tab}
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : t === 'stats' ? 'İstatistik' : 'Yedekler'} onChange={(t: any) => setTab(t)}
</button> 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> </div>
{/* Tables */} {/* Tables */}
{tab === 'tables' && ( {tab === 'tables' && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}> <Card className="overflow-hidden">
{tables.length === 0 {tables.length === 0
? <div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Tablo bulunamadı veya yükleniyor...</div> ? <div className="p-6 text-muted font-mono text-sm text-center">Tablo bulunamadı veya yükleniyor...</div>
: <table style={{ width: '100%', borderCollapse: 'collapse' }}> : <div className="overflow-x-auto">
<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> <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> <tbody>
{tables.map(t => ( {tables.map((t, i) => (
<tr key={t.table_name} style={{ cursor: 'pointer' }} onClick={() => { setTab('query'); setSql(`SELECT * FROM ${t.table_name} LIMIT 50`) }}> <tr
<td style={{ padding: '10px 16px', fontSize: 13, fontFamily: 'monospace', color: 'var(--accent)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{t.table_name}</td> key={t.table_name}
<td style={{ padding: '10px 16px', fontSize: 12, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{Number(t.row_count).toLocaleString()}</td> onClick={() => { setTab('query'); setSql(`SELECT * FROM ${t.table_name} LIMIT 50`) }}
<td style={{ padding: '10px 16px', fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{t.size}</td> 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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
}
</div> </div>
}
</Card>
)} )}
{/* Query */} {/* Query */}
{tab === 'query' && ( {tab === 'query' && (
<div> <div className="space-y-4 fade-up">
<textarea value={sql} onChange={e => setSql(e.target.value)} rows={5} <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() }} 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 }} /> 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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> />
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Cmd+Enter · Sadece SELECT</span>
<button onClick={runQuery} disabled={qLoading} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 20px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
{qLoading ? '...' : '▶ Çalıştır'}
</button>
</div> </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 && ( {result && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}> <Card className="overflow-hidden fade-up">
{result.error {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 className="px-5 py-2.5 border-b border-border text-xs text-muted font-mono bg-black/20">
<div style={{ overflowX: 'auto' }}> {result.rows.length} satır getirildi
<table style={{ width: '100%', borderCollapse: 'collapse' }}> </div>
<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="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> <tbody>
{result.rows.slice(0, 200).map((row, i) => ( {result.rows.slice(0, 200).map((row, i) => (
<tr key={i}> <tr key={i} className="hover:bg-white/[0.01]">
{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>)} {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> </tr>
))} ))}
</tbody> </tbody>
@@ -167,16 +244,16 @@ export default function DatabasesPage() {
</div> </div>
</> </>
} }
</div> </Card>
)} )}
</div> </div>
)} )}
{/* Stats detail */} {/* Stats detail */}
{tab === 'stats' && stats && ( {tab === 'stats' && stats && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}> <Card className="p-6 fade-up">
<pre style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--muted)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(stats, null, 2)}</pre> <pre className="font-mono text-xs text-muted/80 whitespace-pre-wrap leading-relaxed">{JSON.stringify(stats, null, 2)}</pre>
</div> </Card>
)} )}
{/* Backups */} {/* Backups */}
@@ -191,7 +268,7 @@ export default function DatabasesPage() {
} }
function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) { 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 [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [connString, setConnString] = useState('') const [connString, setConnString] = useState('')
@@ -200,13 +277,15 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
setConnString(val) setConnString(val)
try { try {
const u = new URL(val) 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 => ({ setF(p => ({
...p, ...p,
db_type: isMysql ? 'mysql' : 'postgres',
host: u.hostname || p.host, host: u.hostname || p.host,
port: parseInt(u.port) || 5432, port: parseInt(u.port) || (isMysql ? 3306 : 5432),
database: u.pathname.slice(1) || p.database, database: u.pathname.slice(1) || p.database,
username: u.username || p.username, username: u.username || (isMysql ? 'root' : 'postgres'),
password: decodeURIComponent(u.password) || p.password, password: decodeURIComponent(u.password) || p.password,
ssl: u.searchParams.get('sslmode') !== 'disable' && u.searchParams.get('sslmode') !== null ? true : p.ssl 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 () => { const submit = async () => {
setLoading(true); setError('') setLoading(true); setError('')
// Test bağlantısı
try { try {
const testRes = await fetch('/api/db', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', db: { ...f, id: '__test__' } }) }) 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() const testData = await testRes.json()
@@ -233,46 +311,90 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
return return
} }
// Config'e kaydet
const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'database', item: f }) }) 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) } 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 ( return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 420 }} onClick={e => e.stopPropagation()}> <Card className="w-full max-w-[440px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 20 }}>Yeni DB Bağlantısı</div> <CardHeader className="pb-4 border-b border-border/50">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <CardTitle>Yeni Veritabanı Bağlantısı</CardTitle>
<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> <CardDescription>Veritabanı erişimi için gerekli bilgileri girin.</CardDescription>
<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> </CardHeader>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px', gap: 8 }}> <CardContent className="pt-6 space-y-4">
<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="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>
<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 className="grid grid-cols-2 gap-4">
<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 className="space-y-1.5">
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">Tür</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--muted)', cursor: 'pointer' }}> <select
<input type="checkbox" checked={f.ssl} onChange={e => setF(p => ({ ...p, ssl: e.target.checked }))} /> SSL 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> </label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="flex items-center gap-2">
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>RENK</span> <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 }))} style={{ width: 30, height: 26, borderRadius: 4, border: '1px solid var(--border)', cursor: 'pointer', background: 'transparent' }} /> <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> </div>
</div>
{error && <div style={{ color: 'var(--red)', fontSize: 12, fontFamily: 'monospace', marginTop: 10 }}> {error}</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 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> <div className="flex gap-3 pt-4 border-t border-border/50 mt-6">
<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 }}> <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'} {loading ? 'Kaydediliyor...' : 'Kaydet'}
</button> </Button>
</div>
</div> </div>
</CardContent>
</Card>
</div> </div>
) )
} }
@@ -282,7 +404,7 @@ function BackupTab({ db }: { db: Db }) {
const [logs, setLogs] = useState<any[]>([]) const [logs, setLogs] = useState<any[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) 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 () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -292,7 +414,7 @@ function BackupTab({ db }: { db: Db }) {
setConfig(data.config) setConfig(data.config)
setLogs(data.logs) setLogs(data.logs)
if (data.config) { 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) setLoading(false)
@@ -319,65 +441,116 @@ function BackupTab({ db }: { db: Db }) {
body: JSON.stringify({ db_id: db.id }) body: JSON.stringify({ db_id: db.id })
}) })
setConfig(null) 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> if (loading) return <div className="text-muted text-sm font-mono animate-pulse">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
return ( return (
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}> <div className="flex gap-6 items-start fade-up">
<div style={{ flex: 1, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}> <Card className="flex-1">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}> <CardHeader className="flex flex-row items-center justify-between pb-4">
<h3 style={{ fontSize: 15, fontWeight: 700 }}>Otomatik Yedekleme</h3> <div>
<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' }}> <CardTitle className="text-lg">Otomatik Yedekleme</CardTitle>
Hemen İndir <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> </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>
<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 className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Cloud Sağlayıcı</label>
<div style={{ marginBottom: 12 }}> <select
<label style={lbl}>Cloud Sağlayıcı</label> 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"
<select style={inp as any} value={f.cloud_type} onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}> value={f.cloud_type}
onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}
>
<option value="dropbox">Dropbox</option> <option value="dropbox">Dropbox</option>
<option value="gdrive">Google Drive</option> <option value="gdrive">Google Drive</option>
</select> </select>
</div> </div>
<div> <div className="space-y-1.5">
<label style={lbl}>Credentials (JSON veya Token)</label> <label className="text-[10px] font-mono text-muted uppercase tracking-wider">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...'} /> <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>
<div style={{ display: 'flex', gap: 10 }}> {f.cloud_type === 'gdrive' && (
<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> <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 && ( {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>
</div> </CardContent>
</Card>
<div style={{ width: 340, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}> <Card className="w-80 shrink-0">
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>Geçmiş İşlemler</h3> <CardHeader className="pb-4">
{logs.length === 0 ? <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Kayıt yok</div> : ( <CardTitle className="text-sm">Geçmiş İşlemler</CardTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> </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) => ( {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 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 style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> <div className="flex justify-between items-center mb-1">
<span style={{ fontSize: 12, fontWeight: 600, color: log.status === 'success' ? '#00e676' : 'var(--red)' }}>{log.status.toUpperCase()}</span> <span className={`text-[10px] font-bold uppercase tracking-wider ${log.status === 'success' ? 'text-success' : 'text-destructive flex items-center gap-1'}`}>
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{new Date(log.ts * 1000).toLocaleString()}</span> {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> </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 className="text-xs text-muted/80 font-mono mt-2">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-[11px] text-destructive/90 font-mono mt-1.5 leading-tight">{log.message}</div>}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </CardContent>
</Card>
</div> </div>
) )
} }
function ActivityIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
)
}
+240
View File
@@ -0,0 +1,240 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, X, Box, ShieldAlert, CheckCircle2, Play, Square, RotateCw } from 'lucide-react'
type DockerHost = { id: string; name: string; url: string }
type Container = {
Id: string
Names: string[]
Image: string
State: string
Status: string
Ports: { PrivatePort: number; PublicPort?: number; Type: string }[]
}
export default function ClientPage({ initialHosts }: { initialHosts: DockerHost[] }) {
const [hosts, setHosts] = useState<DockerHost[]>(initialHosts)
const [selected, setSelected] = useState<DockerHost | null>(null)
const [containers, setContainers] = useState<Container[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showAdd, setShowAdd] = useState(false)
const loadHosts = useCallback(async () => {
const r = await fetch('/api/config')
const cfg = await r.json()
if (cfg.docker_hosts) setHosts(cfg.docker_hosts)
}, [])
const select = async (host: DockerHost) => {
setSelected(host)
setContainers([])
setError('')
setLoading(true)
try {
const r = await fetch(`/api/docker?hostId=${host.id}`)
const data = await r.json()
if (r.ok) {
setContainers(data)
} else {
setError(data.error || 'Bağlantı hatası')
}
} catch (e: any) {
setError(e.message)
}
setLoading(false)
}
const deleteHost = async (id: string) => {
if (!confirm('Docker bağlantısını sil?')) return
await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', id }) })
if (selected?.id === id) setSelected(null)
loadHosts()
}
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* LEFT */}
<div className="w-64 bg-surface border-r border-border flex flex-col shrink-0">
<div className="p-4 border-b border-border/50 flex justify-between items-center">
<span className="text-xs font-mono text-muted uppercase tracking-widest">Docker Hosts</span>
<Button variant="default" size="sm" onClick={() => setShowAdd(true)} className="h-7 text-xs">
<Plus className="w-3.5 h-3.5 mr-1" /> Ekle
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1 custom-scrollbar">
{hosts.length === 0
? <div className="p-5 text-muted text-xs font-mono text-center">Henüz Host yok</div>
: hosts.map(host => (
<div
key={host.id}
onClick={() => select(host)}
className={`p-3 rounded-xl cursor-pointer transition-all border ${
selected?.id === host.id
? 'bg-accent/5 border-accent/20'
: 'border-transparent hover:bg-surface-2 hover:border-border/50'
}`}
>
<div className="flex items-center gap-2 mb-1.5">
<Box className="w-4 h-4 text-accent" />
<span className="text-sm font-semibold flex-1 truncate">{host.name}</span>
<button onClick={e => { e.stopPropagation(); deleteHost(host.id) }} className="text-muted hover:text-destructive transition-colors shrink-0">
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex justify-between items-center text-[10px] text-muted font-mono pl-6">
<span className="truncate mr-2">{host.url}</span>
</div>
</div>
))}
</div>
</div>
{/* RIGHT */}
<div className="flex-1 overflow-auto custom-scrollbar relative">
{!selected
? (
<div className="absolute inset-0 flex items-center justify-center text-muted font-mono text-sm opacity-50">
<Box className="w-5 h-5 mr-3" />
Sol menüden bir Docker sunucusu seçin
</div>
)
: (
<div className="p-8 max-w-6xl mx-auto fade-up">
<div className="mb-8 flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<Box className="w-6 h-6 text-accent" />
<h2 className="text-2xl font-extrabold tracking-tight">{selected.name}</h2>
</div>
<div className="text-sm text-muted font-mono bg-surface-2 w-fit px-3 py-1 rounded-md border border-border/50">
{selected.url}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => select(selected)} disabled={loading}>
<RotateCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Yenile
</Button>
</div>
{error && (
<div className="p-4 text-destructive font-mono text-sm bg-destructive/5 flex items-center gap-2 border border-destructive/20 rounded-xl mb-6">
<ShieldAlert className="w-4 h-4"/> {error}
</div>
)}
{/* Containers */}
{!error && (
<Card className="overflow-hidden">
{loading && containers.length === 0
? <div className="p-6 text-muted font-mono text-sm text-center animate-pulse">Konteynerler yükleniyor...</div>
: containers.length === 0
? <div className="p-6 text-muted font-mono text-sm text-center">Konteyner bulunamadı.</div>
: <div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr>
{['İsim', 'İmaj', 'Durum', 'Portlar'].map(h => (
<th key={h} className="px-6 py-3 text-[10px] font-mono text-muted uppercase tracking-wider border-b border-border bg-black/20">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{containers.map((c, i) => {
const name = c.Names?.[0]?.replace(/^\//, '') || 'Bilinmiyor'
const isRunning = c.State === 'running'
return (
<tr
key={c.Id}
className="group hover:bg-white/[0.02] transition-colors"
>
<td className={`px-6 py-4 text-sm font-semibold text-accent ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
{name}
</td>
<td className={`px-6 py-4 text-xs font-mono text-muted max-w-[200px] truncate ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
{c.Image}
</td>
<td className={`px-6 py-4 text-xs font-mono ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isRunning ? 'bg-success shadow-[0_0_8px_var(--color-success)]' : 'bg-muted'}`} />
<span className={isRunning ? 'text-success' : 'text-muted'}>{c.Status}</span>
</div>
</td>
<td className={`px-6 py-4 text-[10px] font-mono text-muted ${i !== containers.length-1 ? 'border-b border-border/50' : ''}`}>
<div className="flex flex-wrap gap-1">
{c.Ports?.map((p, idx) => (
<span key={idx} className="bg-surface-2 px-1.5 py-0.5 rounded border border-border/50">
{p.PublicPort ? `${p.PublicPort}->${p.PrivatePort}` : p.PrivatePort}/{p.Type}
</span>
))}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
}
</Card>
)}
</div>
)}
</div>
{showAdd && <AddHostModal onClose={() => setShowAdd(false)} onAdded={() => { loadHosts(); setShowAdd(false) }} />}
</div>
)
}
function AddHostModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
const [f, setF] = useState({ name: '', url: 'http://' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const submit = async () => {
setLoading(true); setError('')
try {
const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'docker', item: f }) })
if (res.ok) { onAdded() } else { setError((await res.json()).error ?? 'Hata'); setLoading(false) }
} catch (e) {
setError('Bağlantı eklenemedi.')
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-[400px] shadow-2xl animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
<CardHeader className="pb-4 border-b border-border/50">
<CardTitle>Yeni Docker Host</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Sunucu Adı</label>
<Input placeholder="Remote VPS" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-mono text-muted uppercase tracking-wider">Docker API URL</label>
<Input placeholder="http://192.168.1.10:2375" value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} />
<p className="text-[10px] text-muted font-mono mt-1">Docker Daemon'ın TCP üzerinden erişilebilir olduğundan emin olun.</p>
</div>
{error && <div className="text-destructive text-xs font-mono bg-destructive/10 p-2 rounded-md border border-destructive/20 mt-2 flex items-center gap-2"><ShieldAlert className="w-3.5 h-3.5"/>{error}</div>}
<div className="flex gap-3 pt-4 border-t border-border/50 mt-6">
<Button variant="ghost" onClick={onClose} className="flex-1">İptal</Button>
<Button onClick={submit} disabled={loading || !f.name || !f.url} className="flex-1">
{loading ? 'Ekleniyor...' : 'Ekle'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { readConfig } from '@/lib/config'
import ClientPage from './ClientPage'
export const dynamic = 'force-dynamic'
export default async function DockerPage() {
const config = await readConfig()
return <ClientPage initialHosts={config.docker_hosts || []} />
}
+27 -18
View File
@@ -1,5 +1,22 @@
@import "tailwindcss"; @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 { :root {
--bg: #0d0f14; --bg: #0d0f14;
--surface: #13161f; --surface: #13161f;
@@ -11,34 +28,26 @@
--red: #ff3d71; --red: #ff3d71;
--purple: #7b61ff; --purple: #7b61ff;
--text: #e2e8f0; --text: #e2e8f0;
--muted: #4a5270; --muted: #8b95a5;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
background: var(--bg); background-color: var(--bg);
color: var(--text); color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh; min-height: 100vh;
margin: 0;
overflow-x: hidden;
} }
body::before { /* Base custom scrollbar */
content: ''; ::-webkit-scrollbar { width: 6px; height: 6px; }
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; }
::-webkit-scrollbar-track { background: transparent; } ::-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 pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
@keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} } @keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
.fade-up { animation: fadeUp .25s ease forwards; } .fade-up { animation: fadeUp .3s ease forwards; }
+62 -17
View File
@@ -1,6 +1,11 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' 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() { export default function LoginPage() {
const router = useRouter() const router = useRouter()
@@ -17,25 +22,65 @@ export default function LoginPage() {
} }
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', zIndex: 1 }}> <div className="min-h-screen flex items-center justify-center relative z-10 bg-background overflow-hidden">
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 16, padding: '40px 36px', width: 360 }}> <GridPattern
<div style={{ marginBottom: 32 }}> squares={[
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--accent)', letterSpacing: -0.5 }}>VPS Panel</div> [4, 4],
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>self-hosted ops dashboard</div> [5, 1],
</div> [8, 2],
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> [5, 5],
<input [10, 10],
type="password" value={pw} onChange={e => setPw(e.target.value)} [12, 15],
placeholder="Şifre" autoFocus [15, 10],
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%' }} [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> </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> </div>
) )
} }
+46 -27
View File
@@ -1,12 +1,16 @@
'use client' 'use client'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { LayoutDashboard, Activity, Database, LineChart, Server, LogOut, Box } from 'lucide-react'
import { cn } from '@/lib/utils'
const nav = [ const nav = [
{ href: '/dashboard', label: 'Overview', emoji: '⊞' }, { href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
{ href: '/dashboard/uptime', label: 'Uptime', emoji: '◎' }, { href: '/dashboard/uptime', label: 'Uptime', icon: Activity },
{ href: '/dashboard/databases', label: 'Databases', emoji: '⊙' }, { href: '/dashboard/databases', label: 'Databases', icon: Database },
{ href: '/dashboard/analytics', label: 'Analytics', emoji: '◈' }, { href: '/dashboard/analytics', label: 'Analytics', icon: LineChart },
{ href: '/dashboard/services', label: 'Servisler', emoji: '⊛' }, { href: '/dashboard/services', label: 'Servisler', icon: Server },
{ href: '/dashboard/docker', label: 'Docker', icon: Box },
] ]
export default function Sidebar() { export default function Sidebar() {
@@ -19,40 +23,55 @@ export default function Sidebar() {
} }
return ( 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 }}> <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 style={{ padding: '0 16px 20px', borderBottom: '1px solid var(--border)', marginBottom: 12 }}> <div className="px-6 pb-6 border-b border-border/50 mb-4">
<div style={{ fontSize: 16, fontWeight: 800, color: 'var(--accent)' }}>VPS Panel</div> <div className="text-lg font-extrabold text-accent flex items-center gap-2">
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 2, letterSpacing: 1 }}>OPS DASHBOARD</div> <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>
<div style={{ flex: 1 }}> <div className="flex-1 px-3 space-y-1">
{nav.map(item => { {nav.map(item => {
const active = item.href === '/dashboard' ? path === item.href : path.startsWith(item.href) const active = item.href === '/dashboard' ? path === item.href : path.startsWith(item.href)
const Icon = item.icon
return ( return (
<a key={item.href} href={item.href} style={{ <a
display: 'flex', alignItems: 'center', gap: 9, key={item.href}
padding: '9px 16px', fontSize: 13, fontWeight: 600, href={item.href}
color: active ? 'var(--accent)' : 'var(--muted)', className={cn(
borderLeft: `2px solid ${active ? 'var(--accent)' : 'transparent'}`, "flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all group",
background: active ? 'rgba(0,229,255,.05)' : 'transparent', active
textDecoration: 'none', transition: 'all .15s', ? "bg-accent/10 text-accent"
}}> : "text-muted hover:text-text hover:bg-surface-2"
<span style={{ fontSize: 15 }}>{item.emoji}</span> )}
>
<Icon className={cn("w-4 h-4 transition-transform", active ? "scale-110" : "group-hover:scale-110")} />
{item.label} {item.label}
</a> </a>
) )
})} })}
</div> </div>
<div style={{ padding: '16px', borderTop: '1px solid var(--border)' }}> <div className="p-4 mx-3 mt-auto border-t border-border/50">
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 }}> <div className="flex items-center gap-2 text-xs text-muted font-mono mb-4 px-2">
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--green)', display: 'inline-block', boxShadow: '0 0 6px var(--green)', animation: 'pulse 2s infinite' }} /> <span className="relative flex h-2 w-2">
aktif <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> </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)' }} <button
onMouseOut={e => { const t = e.currentTarget; t.style.color = 'var(--muted)'; t.style.borderColor = 'var(--border)' }}> onClick={logout}
Çıkış 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> </button>
</div> </div>
</nav> </nav>
+46
View File
@@ -0,0 +1,46 @@
'use client'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
interface Tab {
id: string
label: string
icon?: React.ReactNode
}
interface AnimatedTabsProps {
tabs: Tab[]
activeTab: string
onChange: (id: string) => void
className?: string
}
export function AnimatedTabs({ tabs, activeTab, onChange, className }: AnimatedTabsProps) {
return (
<div className={cn("flex space-x-1 rounded-xl bg-black/20 p-1 border border-border w-fit", className)}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={cn(
"relative flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors outline-none",
activeTab === tab.id ? "text-accent" : "text-muted hover:text-text/80"
)}
>
{activeTab === tab.id && (
<motion.div
layoutId="bubble"
className="absolute inset-0 bg-accent/10 border border-accent/20 rounded-lg shadow-sm"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
<span className="relative z-10 flex items-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
))}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { motion, HTMLMotionProps } from 'framer-motion'
export interface ButtonProps extends HTMLMotionProps<"button"> {
variant?: 'default' | 'outline' | 'ghost' | 'danger'
size?: 'default' | 'sm' | 'lg' | 'icon'
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<motion.button
ref={ref}
whileTap={{ scale: 0.98 }}
whileHover={{ scale: 1.02 }}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50',
{
'bg-accent/10 text-accent border border-accent/30 hover:bg-accent/20': variant === 'default',
'border border-border bg-transparent hover:bg-surface-2 text-text': variant === 'outline',
'hover:bg-surface-2 hover:text-text text-muted': variant === 'ghost',
'bg-destructive/10 text-destructive border border-destructive/30 hover:bg-destructive/20': variant === 'danger',
'h-9 px-4 py-2': size === 'default',
'h-8 rounded-md px-3 text-xs': size === 'sm',
'h-10 rounded-md px-8': size === 'lg',
'h-9 w-9': size === 'icon',
},
className
)}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button }
+69
View File
@@ -0,0 +1,69 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border border-border bg-surface/50 backdrop-blur-md text-text shadow-sm relative overflow-hidden group',
className
)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight text-accent', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted font-mono', className)}
{...props}
/>
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+71
View File
@@ -0,0 +1,71 @@
'use client'
import { useId } from 'react'
import { cn } from '@/lib/utils'
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
width?: number
height?: number
x?: number
y?: number
squares?: Array<[x: number, y: number]>
strokeDasharray?: string
className?: string
[key: string]: any
}
export function GridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = '0',
squares,
className,
...props
}: GridPatternProps) {
const id = useId()
return (
<svg
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 h-full w-full fill-white/[0.02] stroke-white/[0.02]',
className
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
{squares && (
<svg x={x} y={y} className="overflow-visible">
{squares.map(([x, y]) => (
<rect
strokeWidth="0"
key={`${x}-${y}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
/>
))}
</svg>
)}
</svg>
)
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-border bg-black/30 px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50 font-mono',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }
+13 -1
View File
@@ -55,8 +55,10 @@ pool.query(`
username TEXT NOT NULL, username TEXT NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
ssl BOOLEAN NOT NULL DEFAULT FALSE, 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 ( CREATE TABLE IF NOT EXISTS config_services (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -66,15 +68,25 @@ pool.query(`
description TEXT 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 ( CREATE TABLE IF NOT EXISTS backup_configs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
db_id TEXT NOT NULL, db_id TEXT NOT NULL,
schedule TEXT NOT NULL, schedule TEXT NOT NULL,
cloud_type TEXT NOT NULL, cloud_type TEXT NOT NULL,
credentials TEXT NOT NULL, credentials TEXT NOT NULL,
gdrive_folder_id TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT) created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
); );
ALTER TABLE backup_configs ADD COLUMN IF NOT EXISTS gdrive_folder_id TEXT;
CREATE TABLE IF NOT EXISTS backup_logs ( CREATE TABLE IF NOT EXISTS backup_logs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
db_id TEXT NOT NULL, db_id TEXT NOT NULL,
+31 -30
View File
@@ -11,41 +11,39 @@ export interface Database {
password: string password: string
ssl: boolean ssl: boolean
color?: string color?: string
db_type?: 'postgres' | 'mysql'
} }
export async function createPgDumpStream(db: Database) { export async function createDbDumpBuffer(db: Database): Promise<Buffer> {
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> {
return new Promise((resolve, reject) => { 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, '-h', db.host,
'-p', db.port.toString(), '-p', db.port.toString(),
'-U', db.username, '-U', db.username,
'-d', db.database, '-d', db.database,
'-F', 'p', '-F', 'p',
'--no-owner', '--no-owner',
], { ]
env: { ...process.env, PGPASSWORD: db.password }, env.PGPASSWORD = db.password
}) }
const child = spawn(command, args, { env })
const chunks: Buffer[] = [] const chunks: Buffer[] = []
let errorOutput = '' let errorOutput = ''
@@ -57,12 +55,12 @@ export async function createPgDumpBuffer(db: Database): Promise<Buffer> {
if (code === 0) { if (code === 0) {
resolve(Buffer.concat(chunks)) resolve(Buffer.concat(chunks))
} else { } else {
reject(new Error(`pg_dump exited with code ${code}: ${errorOutput}`)) reject(new Error(`${command} exited with code ${code}: ${errorOutput}`))
} }
}) })
child.on('error', (err) => { 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() 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) const credentials = JSON.parse(serviceAccountJsonStr)
// 1. Get Access Token using jose // 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 delimiter = `\r\n--${boundary}\r\n`
const close_delim = `\r\n--${boundary}--` const close_delim = `\r\n--${boundary}--`
const metadata = { const metadata: any = {
name: filename, name: filename,
mimeType: 'application/sql' mimeType: 'application/sql'
} }
if (folderId) {
metadata.parents = [folderId]
}
const multipartRequestBody = Buffer.concat([ const multipartRequestBody = Buffer.concat([
Buffer.from(delimiter + 'Content-Type: application/json\r\n\r\n' + JSON.stringify(metadata) + '\r\n'), Buffer.from(delimiter + 'Content-Type: application/json\r\n\r\n' + JSON.stringify(metadata) + '\r\n'),
+26 -8
View File
@@ -21,6 +21,7 @@ export interface Database {
password: string password: string
ssl: boolean ssl: boolean
color: string color: string
db_type: 'postgres' | 'mysql'
} }
export interface Service { export interface Service {
@@ -31,10 +32,17 @@ export interface Service {
description: string description: string
} }
export interface DockerHost {
id: string
name: string
url: string
}
export interface Config { export interface Config {
sites: Site[] sites: Site[]
databases: Database[] databases: Database[]
services: Service[] services: Service[]
docker_hosts: DockerHost[]
} }
export function generateId(): string { 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]) 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 || []) { 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 || []) { 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]) 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 // Dosyanın uzantısını değiştirerek migration'ın bittiğini işaretle
fs.renameSync(CONFIG_PATH, CONFIG_PATH + '.bak') fs.renameSync(CONFIG_PATH, CONFIG_PATH + '.bak')
@@ -69,43 +80,50 @@ export async function readConfig(): Promise<Config> {
} }
// DB'den oku // 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_sites`),
pool.query(`SELECT * FROM config_databases`), pool.query(`SELECT * FROM config_databases`),
pool.query(`SELECT * FROM config_services`), pool.query(`SELECT * FROM config_services`),
pool.query(`SELECT * FROM config_docker_hosts`),
]) ])
return { return {
sites: sitesRes.rows as Site[], sites: sitesRes.rows as Site[],
databases: dbsRes.rows as Database[], 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() } const newItem = { ...item, id: generateId() }
if (type === 'site') { 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]) 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') { } 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') { } 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]) 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 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') { 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]) 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') { } 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') { } 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]) 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]) 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 === '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 === 'service') await pool.query(`DELETE FROM config_services WHERE id=$1`, [id])
else if (type === 'docker') await pool.query(`DELETE FROM config_docker_hosts WHERE id=$1`, [id])
} }
+4 -13
View File
@@ -1,24 +1,15 @@
import * as cron from 'node-cron' import * as cron from 'node-cron'
import pool from './appDb' import pool from './appDb'
import { readConfig } from './config' import { readConfig } from './config'
import { createPgDumpBuffer, uploadToDropbox, uploadToGoogleDrive } from './backup' import { createDbDumpBuffer, uploadToDropbox, uploadToGoogleDrive } from './backup'
export function startBackupCron() { export function startBackupCron() {
console.log('[AutoBackup] Arka plan cron servisi başlatıldı.') 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>() const activeTasks = new Map<string, cron.ScheduledTask>()
export async function reloadBackupCrons() { export async function reloadBackupCrons() {
// Clear existing
for (const [id, task] of activeTasks.entries()) { for (const [id, task] of activeTasks.entries()) {
task.stop() task.stop()
} }
@@ -30,13 +21,13 @@ export async function reloadBackupCrons() {
for (const row of rows) { for (const row of rows) {
const db = config.databases.find(d => d.id === row.db_id) 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 () => { const task = cron.schedule(row.schedule, async () => {
try { try {
console.log(`[Backup] ${db.name} (${row.cloud_type}) başlatılıyor...`) 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 dateStr = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${db.name}_${dateStr}.sql` const filename = `${db.name}_${dateStr}.sql`
let message = 'Success' let message = 'Success'
@@ -45,7 +36,7 @@ export async function reloadBackupCrons() {
if (row.cloud_type === 'dropbox') { if (row.cloud_type === 'dropbox') {
await uploadToDropbox(row.credentials, filename, buffer) await uploadToDropbox(row.credentials, filename, buffer)
} else if (row.cloud_type === 'gdrive') { } 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) { } catch (uploadErr: any) {
console.error(`[Backup] Upload hatası:`, uploadErr) console.error(`[Backup] Upload hatası:`, uploadErr)
+97 -11
View File
@@ -1,41 +1,89 @@
import { Pool } from 'pg' import { Pool as PgPool } from 'pg'
import mysql from 'mysql2/promise'
import { Database } from './config' 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 { function getPgPool(db: Database): PgPool {
if (pools.has(db.id)) return pools.get(db.id)! if (pgPools.has(db.id)) return pgPools.get(db.id)!
const pool = new Pool({ const pool = new PgPool({
host: db.host, port: db.port, database: db.database, host: db.host, port: db.port, database: db.database,
user: db.username, password: db.password, user: db.username, password: db.password,
ssl: db.ssl ? { rejectUnauthorized: false } : false, ssl: db.ssl ? { rejectUnauthorized: false } : false,
max: 3, connectionTimeoutMillis: 8000, 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 return pool
} }
export async function testDb(db: Database) { export async function testDb(db: Database) {
const start = Date.now() const start = Date.now()
try { 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, host: db.host, port: db.port, database: db.database,
user: db.username, password: db.password, user: db.username, password: db.password,
ssl: db.ssl ? { rejectUnauthorized: false } : false, ssl: db.ssl ? { rejectUnauthorized: false } : false,
max: 1, connectionTimeoutMillis: 5000, max: 1, connectionTimeoutMillis: 5000,
}) : getPool(db) }) : getPgPool(db)
const client = await pool.connect() const client = await pool.connect()
const res = await client.query('SELECT version()') const res = await client.query('SELECT version()')
client.release() client.release()
if (db.id === '__test__') await pool.end() 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(' ') } return { ok: true, ms: Date.now() - start, version: (res.rows[0].version as string).split(' ').slice(0, 2).join(' ') }
}
} catch (e: unknown) { } catch (e: unknown) {
return { ok: false, ms: Date.now() - start, error: e instanceof Error ? e.message : 'Hata' } return { ok: false, ms: Date.now() - start, error: e instanceof Error ? e.message : 'Hata' }
} }
} }
export async function getDbStats(db: Database) { 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() const client = await pool.connect()
try { try {
const [size, conns, tables] = await Promise.all([ const [size, conns, tables] = await Promise.all([
@@ -49,10 +97,36 @@ export async function getDbStats(db: Database) {
table_count: tables.rows[0].cnt, table_count: tables.rows[0].cnt,
} }
} finally { client.release() } } finally { client.release() }
}
} }
export async function getTables(db: Database) { 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() const client = await pool.connect()
try { try {
const res = await client.query(` const res = await client.query(`
@@ -66,6 +140,7 @@ export async function getTables(db: Database) {
`) `)
return res.rows return res.rows
} finally { client.release() } } finally { client.release() }
}
} }
export async function runQuery(db: Database, sql: string) { export async function runQuery(db: Database, sql: string) {
@@ -74,7 +149,17 @@ export async function runQuery(db: Database, sql: string) {
if (blocked.some(k => trimmed.startsWith(k))) if (blocked.some(k => trimmed.startsWith(k)))
return { rows: [], fields: [], error: 'Sadece SELECT sorguları çalıştırılabilir.' } 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() const client = await pool.connect()
try { try {
const res = await client.query(sql) const res = await client.query(sql)
@@ -82,4 +167,5 @@ export async function runQuery(db: Database, sql: string) {
} catch (e: unknown) { } catch (e: unknown) {
return { rows: [], fields: [], error: e instanceof Error ? e.message : 'Sorgu hatası' } return { rows: [], fields: [], error: e instanceof Error ? e.message : 'Sorgu hatası' }
} finally { client.release() } } finally { client.release() }
}
} }
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}