Initial commit

This commit is contained in:
mstfyldz
2026-05-27 16:47:37 +03:00
commit 3ee41864f4
40 changed files with 9041 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

115
config.json Normal file
View File

@@ -0,0 +1,115 @@
{
"sites": [
{
"name": "Ayirs Tech",
"url": "https://ayris.tech",
"interval_min": 1,
"id": "1779887873514-4l00t"
},
{
"name": "Onx Control",
"url": "http://onxcontrol.com",
"interval_min": 1,
"id": "1779887898264-992ox"
}
],
"databases": [
{
"name": "Leyüze Butik Chat",
"host": "65.109.236.58",
"port": 3647,
"database": "postgres",
"username": "postgres",
"password": "4T1FnNSDlhhWT1W5bOp3ti6LKw4AFzCarrfXLo2lUuXP8iEvUFWQ3SZpGgnK4C5X",
"ssl": false,
"color": "#00e5ff",
"id": "1779888243791-oyi2i"
},
{
"name": "AyçaNurTuran",
"host": "65.109.236.58",
"port": 6482,
"database": "postgres",
"username": "postgres",
"password": "P9cIY8Ji1iSXOCRs9q6WbOo5xeXCdzyQjYoQ511Zmq1RY8WHLU9YKBGyjDpJ02sa",
"ssl": false,
"color": "#00e5ff",
"id": "1779888647024-853cg"
},
{
"name": "AyrisApart",
"host": "65.109.236.58",
"port": 9435,
"database": "postgres",
"username": "postgres",
"password": "byCfEjXq0Tflw4K0mZ74qczYoajwcUFJFl5WWNTHoxOirgw1NDyoJMEVtyCJBBbk",
"ssl": false,
"color": "#00e5ff",
"id": "1779889157629-xpe3j"
},
{
"name": "X-Tracker",
"host": "65.109.236.58",
"port": 5433,
"database": "postgres",
"username": "postgres",
"password": "6UuYjVZ13ZKfmgKRIsXiCqNjLriAkvugGG9awYqM4BXo78Sg39JypbyNgV72K0zY",
"ssl": false,
"color": "#00e5ff",
"id": "1779889184466-xsq40"
},
{
"name": "Luna QR Menu",
"host": "65.109.236.58",
"port": 4378,
"database": "postgres",
"username": "postgres",
"password": "Oesb5U5YQQAgIByAZZVn3WeL9hqFME72ReI0nUD5EGC546fUINxuheEaLbvsL3jM",
"ssl": false,
"color": "#00e5ff",
"id": "1779889217515-g9t8s"
},
{
"name": "Mugla Dijital Medya",
"host": "65.109.236.58",
"port": 8392,
"database": "postgres",
"username": "postgres",
"password": "xGhPj4IuE5VocaxUoYAj1dSr2xf6M3hh3c2C6YbnB7ZOeVJLRvmL0mzCbhvf14dh",
"ssl": false,
"color": "#00e5ff",
"id": "1779889251060-zk9yi"
},
{
"name": "App-Admin",
"host": "65.109.236.58",
"port": 7397,
"database": "postgres",
"username": "postgres",
"password": "D7Vk2Pu55kixWc0vTqRXsn2SEHtlbcdZBDavGkirOQM45rsYf2QFhKLBzhggvE6c",
"ssl": false,
"color": "#00e5ff",
"id": "1779889294957-vbql4"
},
{
"name": "VPS Panel",
"host": "65.109.236.58",
"port": 4389,
"database": "postgres",
"username": "postgres",
"password": "8VwBw9pepLzDpJITPies6yUVp9SHHsjgZlEDSyM4L3m7JmnP32ywO28FHw05PTUL",
"ssl": false,
"color": "#00e5ff",
"id": "1779889337654-9bfak"
}
],
"services": [
{
"id": "svc-1",
"name": "Coolify",
"url": "http://localhost:8000",
"icon": "⚙️",
"description": "Deploy yönetimi"
}
]
}

BIN
data/logs.db Normal file

Binary file not shown.

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6825
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "vps-panel",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0",
"jose": "^6.2.3",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import { insertPageview } from '@/lib/appDb'
import { createHash } from 'crypto'
// UA parse — basit ama yeterli
function parseUA(ua: string) {
const device = /mobile|android|iphone|ipad/i.test(ua) ? 'Mobile'
: /tablet|ipad/i.test(ua) ? 'Tablet' : 'Desktop'
const browser = /edg\//i.test(ua) ? 'Edge'
: /chrome|chromium/i.test(ua) ? 'Chrome'
: /firefox/i.test(ua) ? 'Firefox'
: /safari/i.test(ua) ? 'Safari'
: /opera|opr/i.test(ua) ? 'Opera'
: 'Other'
const os = /windows/i.test(ua) ? 'Windows'
: /mac os/i.test(ua) ? 'macOS'
: /android/i.test(ua) ? 'Android'
: /iphone|ipad/i.test(ua) ? 'iOS'
: /linux/i.test(ua) ? 'Linux'
: 'Other'
return { device, browser, os }
}
// IP → anonim visitor_id (GDPR dostu, Plausible gibi)
function visitorId(ip: string, ua: string, domain: string, date: string): string {
const hash = createHash('sha256')
.update(`${ip}${ua}${domain}${date}${process.env.JWT_SECRET ?? 'salt'}`)
.digest('hex')
return hash.slice(0, 16)
}
export async function POST(req: NextRequest) {
// CORS — herhangi bir domain gönderebilsin
const origin = req.headers.get('origin') ?? '*'
try {
const body = await req.json()
const { domain, path, referrer } = body
if (!domain || !path) {
return new NextResponse('bad request', { status: 400 })
}
const ua = req.headers.get('user-agent') ?? ''
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? req.headers.get('x-real-ip')
?? '0.0.0.0'
// Bot filter
if (/bot|crawler|spider|headless|lighthouse|pagespeed/i.test(ua)) {
return new NextResponse('ok', { status: 200 })
}
const { device, browser, os } = parseUA(ua)
const today = new Date().toISOString().slice(0, 10)
const vid = visitorId(ip, ua, domain, today)
// Ülke bilgisi — CF header varsa kullan (Cloudflare), yoksa boş
const country = req.headers.get('cf-ipcountry') ?? null
const city = req.headers.get('cf-ipcity') ?? null
await insertPageview({
domain,
path,
referrer: referrer || null,
country,
city,
ua,
device,
browser,
os,
visitor_id: vid,
})
return new NextResponse('ok', {
status: 200,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
} catch {
return new NextResponse('error', { status: 500 })
}
}
// CORS preflight
export async function OPTIONS(req: NextRequest) {
const origin = req.headers.get('origin') ?? '*'
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
// Bu endpoint tracking script'i serve eder
// Sitelerde: <script defer src="https://panel.domain.com/api/analytics/script"></script>
// data-domain attribute'u ile domain belirtilir
export async function GET(req: NextRequest) {
const panelUrl = req.nextUrl.origin
const script = `
(function() {
var d = document.currentScript && document.currentScript.getAttribute('data-domain');
if (!d) d = location.hostname;
function send(path, ref) {
var payload = {
domain: d,
path: path,
referrer: ref || document.referrer || ''
};
var url = '${panelUrl}/api/analytics/collect';
if (navigator.sendBeacon) {
navigator.sendBeacon(url, JSON.stringify(payload));
} else {
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), keepalive: true });
}
}
// İlk pageview
send(location.pathname, document.referrer);
// SPA route değişikliklerini yakala (Next.js, React Router)
var _push = history.pushState;
history.pushState = function() {
_push.apply(history, arguments);
send(location.pathname, '');
};
window.addEventListener('popstate', function() {
send(location.pathname, '');
});
})();
`.trim()
return new NextResponse(script, {
headers: {
'Content-Type': 'application/javascript',
'Cache-Control': 'public, max-age=3600',
},
})
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getAnalyticsStats, getAllDomains } from '@/lib/appDb'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const domain = req.nextUrl.searchParams.get('domain')
const days = parseInt(req.nextUrl.searchParams.get('days') ?? '30')
if (!domain) {
// Domain listesi döndür
const domains = await getAllDomains()
return NextResponse.json({ domains })
}
const stats = await getAnalyticsStats(domain, days)
return NextResponse.json(stats)
}

View File

@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { createToken, COOKIE } from '@/lib/auth'
export async function POST(req: NextRequest) {
const { password } = await req.json()
if (password !== process.env.PANEL_SECRET)
return NextResponse.json({ error: 'Yanlış şifre' }, { status: 401 })
const token = await createToken()
const res = NextResponse.json({ ok: true })
res.cookies.set(COOKIE, token, { httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, path: '/' })
return res
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server'
import { COOKIE } from '@/lib/auth'
export async function POST() {
const res = NextResponse.json({ ok: true })
res.cookies.delete(COOKIE)
return res
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { readConfig, writeConfig, generateId } from '@/lib/config'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
return NextResponse.json(readConfig())
}
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { type, item } = await req.json()
const config = readConfig()
const newItem = { ...item, id: generateId() }
if (type === 'site') config.sites.push(newItem)
else if (type === 'database') config.databases.push(newItem)
else if (type === 'service') config.services.push(newItem)
else return NextResponse.json({ error: 'Geçersiz type' }, { status: 400 })
writeConfig(config)
return NextResponse.json(newItem)
}
export async function DELETE(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { type, id } = await req.json()
const config = readConfig()
if (type === 'site') config.sites = config.sites.filter(s => s.id !== id)
else if (type === 'database') config.databases = config.databases.filter(d => d.id !== id)
else if (type === 'service') config.services = config.services.filter(s => s.id !== id)
writeConfig(config)
return NextResponse.json({ ok: true })
}
export async function PUT(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { type, id, item } = await req.json()
const config = readConfig()
if (type === 'site') {
const idx = config.sites.findIndex(s => s.id === id)
if (idx !== -1) config.sites[idx] = { ...config.sites[idx], ...item }
} else if (type === 'database') {
const idx = config.databases.findIndex(d => d.id === id)
if (idx !== -1) config.databases[idx] = { ...config.databases[idx], ...item }
} else if (type === 'service') {
const idx = config.services.findIndex(s => s.id === id)
if (idx !== -1) config.services[idx] = { ...config.services[idx], ...item }
} else {
return NextResponse.json({ error: 'Geçersiz type' }, { status: 400 })
}
writeConfig(config)
return NextResponse.json({ ok: true })
}

45
src/app/api/db/route.ts Normal file
View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { readConfig } from '@/lib/config'
import { testDb, getDbStats, getTables, runQuery } from '@/lib/db'
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const dbId = req.nextUrl.searchParams.get('dbId')
const action = req.nextUrl.searchParams.get('action') ?? 'stats'
if (!dbId) return NextResponse.json({ error: 'dbId gerekli' }, { status: 400 })
const config = readConfig()
const db = config.databases.find(d => d.id === dbId)
if (!db) return NextResponse.json({ error: 'DB bulunamadı' }, { status: 404 })
if (action === 'test') return NextResponse.json(await testDb(db))
try {
if (action === 'stats') return NextResponse.json(await getDbStats(db))
if (action === 'tables') return NextResponse.json(await getTables(db))
} catch (e: unknown) {
return NextResponse.json({ error: e instanceof Error ? e.message : 'Bağlantı hatası' }, { status: 400 })
}
return NextResponse.json({ error: 'Geçersiz action' }, { status: 400 })
}
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const body = await req.json()
if (body.action === 'test') {
return NextResponse.json(await testDb(body.db))
}
const { dbId, sql } = body
const config = readConfig()
const db = config.databases.find(d => d.id === dbId)
if (!db) return NextResponse.json({ error: 'DB bulunamadı' }, { status: 404 })
return NextResponse.json(await runQuery(db, sql))
}

46
src/app/api/ping/route.ts Normal file
View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { readConfig } from '@/lib/config'
import { pingAndLog } from '@/lib/ping'
import { getRecentLogs, getUptimePercent, getLastLog } from '@/lib/appDb'
// GET /api/ping?siteId=xxx → logs + uptime
export async function GET(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const siteId = req.nextUrl.searchParams.get('siteId')
if (!siteId) return NextResponse.json({ error: 'siteId gerekli' }, { status: 400 })
const logs = await getRecentLogs(siteId, 100)
const uptime24 = await getUptimePercent(siteId, 24)
const uptime7d = await getUptimePercent(siteId, 168)
const last = await getLastLog(siteId)
return NextResponse.json({ logs, uptime24, uptime7d, last })
}
// POST /api/ping { siteId } → ping now
export async function POST(req: NextRequest) {
const err = await requireAuth(req)
if (err) return err
const { siteId } = await req.json()
const config = readConfig()
const site = config.sites.find(s => s.id === siteId)
if (!site) return NextResponse.json({ error: 'Site bulunamadı' }, { status: 404 })
const result = await pingAndLog(site)
return NextResponse.json(result)
}
// POST /api/ping/all → ping all sites (cron için)
export async function PUT(req: NextRequest) {
const secret = req.headers.get('x-cron-secret')
if (secret !== process.env.PANEL_SECRET)
return NextResponse.json({ error: 'Yetkisiz' }, { status: 401 })
const config = readConfig()
const results = await Promise.all(config.sites.map(site => pingAndLog(site).then(r => ({ id: site.id, ...r }))))
return NextResponse.json(results)
}

View File

@@ -0,0 +1,262 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
type Stats = {
total: number
unique: number
topPages: { path: string; views: number }[]
topReferrers: { referrer: string; visits: number }[]
byDevice: { device: string; n: number }[]
byBrowser: { browser: string; n: number }[]
byOs: { os: string; n: number }[]
byCountry: { country: string; n: number }[]
daily: { day: string; views: number; visitors: number }[]
}
const DAYS_OPTIONS = [
{ label: '7g', value: 7 },
{ label: '30g', value: 30 },
{ label: '90g', value: 90 },
]
function Bar({ label, value, max, color = 'var(--accent)' }: { label: string; value: number; max: number; color?: string }) {
const pct = max > 0 ? (value / max) * 100 : 0
return (
<div style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: 'var(--text)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, marginRight: 8 }}>{label}</span>
<span style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', flexShrink: 0 }}>{value.toLocaleString()}</span>
</div>
<div style={{ height: 4, background: 'rgba(255,255,255,.06)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 2, transition: 'width .5s ease' }} />
</div>
</div>
)
}
function MiniChart({ data, days }: { data: { day: string; views: number; visitors: number }[]; days: number }) {
if (!data.length) return <div style={{ height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Henüz veri yok</div>
// Tüm günleri doldur
const filled: { day: string; views: number; visitors: number }[] = []
const now = new Date()
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now)
d.setDate(d.getDate() - i)
const key = d.toISOString().slice(0, 10)
const found = data.find(r => r.day === key)
filled.push(found ?? { day: key, views: 0, visitors: 0 })
}
const maxViews = Math.max(...filled.map(d => d.views), 1)
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 80, padding: '0 4px' }}>
{filled.map((d, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, height: '100%', justifyContent: 'flex-end' }}
title={`${d.day}: ${d.views} görüntüleme, ${d.visitors} ziyaretçi`}>
<div style={{ width: '100%', background: 'var(--accent)', borderRadius: '2px 2px 0 0', opacity: .85, height: `${Math.max((d.views / maxViews) * 100, d.views > 0 ? 4 : 0)}%`, transition: 'height .3s ease', minHeight: d.views > 0 ? 2 : 0 }} />
</div>
))}
</div>
)
}
export default function AnalyticsPage() {
const [domains, setDomains] = useState<string[]>([])
const [selectedDomain, setSelectedDomain] = useState<string | null>(null)
const [days, setDays] = useState(30)
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(false)
const [showSnippet, setShowSnippet] = useState(false)
const [panelUrl, setPanelUrl] = useState('')
useEffect(() => {
setPanelUrl(window.location.origin)
fetch('/api/analytics/stats')
.then(r => r.json())
.then(d => {
setDomains(d.domains ?? [])
if (d.domains?.length > 0) setSelectedDomain(d.domains[0])
})
}, [])
const loadStats = useCallback(async () => {
if (!selectedDomain) return
setLoading(true)
const r = await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}&days=${days}`)
setStats(await r.json())
setLoading(false)
}, [selectedDomain, days])
useEffect(() => { loadStats() }, [loadStats])
const snippet = `<!-- VPS Panel Analytics -->
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
return (
<div style={{ padding: 28, maxWidth: 1100 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Analytics</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cookie-free, self-hosted Plausible benzeri</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={() => setShowSnippet(true)}
style={{ background: 'rgba(123,97,255,.12)', border: '1px solid rgba(123,97,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--purple, #7b61ff)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
{'</>'} Snippet
</button>
{/* Days filter */}
<div style={{ display: 'flex', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 7, overflow: 'hidden' }}>
{DAYS_OPTIONS.map(opt => (
<button key={opt.value} onClick={() => setDays(opt.value)}
style={{ padding: '7px 14px', fontSize: 12, fontFamily: 'monospace', cursor: 'pointer', background: days === opt.value ? 'rgba(0,229,255,.12)' : 'transparent', color: days === opt.value ? 'var(--accent)' : 'var(--muted)', border: 'none', borderRight: '1px solid var(--border)' }}>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* Domain tabs */}
{domains.length > 0 ? (
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{domains.map(d => (
<button key={d} onClick={() => setSelectedDomain(d)}
style={{ padding: '8px 14px', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'none', border: 'none', borderBottom: `2px solid ${selectedDomain === d ? 'var(--accent)' : 'transparent'}`, color: selectedDomain === d ? 'var(--accent)' : 'var(--muted)', marginBottom: -1 }}>
{d}
</button>
))}
</div>
) : (
<div style={{ background: 'var(--surface)', border: '1px dashed var(--border)', borderRadius: 12, padding: '40px', textAlign: 'center', marginBottom: 20 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>Henüz veri yok</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 16 }}>Sitelerine tracking snippet ekle, veriler burada görünecek.</div>
<button onClick={() => setShowSnippet(true)}
style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 7, padding: '9px 18px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontFamily: 'monospace' }}>
{'</>'} Snippet&apos;ı Göster
</button>
</div>
)}
{stats && !loading && (
<>
{/* Big numbers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 14, marginBottom: 20 }}>
{[
{ label: 'Toplam Görüntüleme', value: stats.total.toLocaleString(), color: 'var(--accent)' },
{ label: 'Tekil Ziyaretçi', value: stats.unique.toLocaleString(), color: 'var(--green)' },
].map(s => (
<div key={s.label} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '18px 20px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>{s.label}</div>
<div style={{ fontSize: 32, fontWeight: 800, color: s.color, letterSpacing: -1 }}>{s.value}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>son {days} gün</div>
</div>
))}
</div>
{/* Chart */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 20px', marginBottom: 20 }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 12 }}>Günlük Görüntülemeler</div>
<MiniChart data={stats.daily} days={days} />
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6 }}>
<span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>{days} gün önce</span>
<span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>bugün</span>
</div>
</div>
{/* 3-col breakdown */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14, marginBottom: 14 }}>
{/* Top pages */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Sayfalar</div>
{stats.topPages.length === 0
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Veri yok</div>
: stats.topPages.map(p => <Bar key={p.path} label={p.path || '/'} value={p.views} max={stats.topPages[0]?.views ?? 1} />)
}
</div>
{/* Referrers */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Trafik Kaynağı</div>
{stats.topReferrers.length === 0
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Direct / bilinmiyor</div>
: stats.topReferrers.map(r => {
let label = r.referrer
try { label = new URL(r.referrer).hostname } catch { /* ignore */ }
return <Bar key={r.referrer} label={label} value={r.visits} max={stats.topReferrers[0]?.visits ?? 1} color="var(--green)" />
})
}
</div>
{/* Countries */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>Ülkeler</div>
{stats.byCountry.length === 0
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Cloudflare proxy gerekli</div>
: stats.byCountry.map(c => <Bar key={c.country} label={c.country} value={c.n} max={stats.byCountry[0]?.n ?? 1} color="var(--yellow)" />)
}
</div>
</div>
{/* Device / Browser / OS */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14 }}>
{[
{ title: 'Cihaz', data: stats.byDevice.map(d => ({ label: d.device, value: d.n })) },
{ title: 'Tarayıcı', data: stats.byBrowser.map(d => ({ label: d.browser, value: d.n })) },
{ title: 'İşletim Sistemi', data: stats.byOs.map(d => ({ label: d.os, value: d.n })) },
].map(section => (
<div key={section.title} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 14 }}>{section.title}</div>
{section.data.length === 0
? <div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Veri yok</div>
: section.data.map(d => <Bar key={d.label} label={d.label} value={d.value} max={section.data[0]?.value ?? 1} color="rgba(123,97,255,.8)" />)
}
</div>
))}
</div>
</>
)}
{loading && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}>Yükleniyor...</div>
)}
{/* Snippet modal */}
{showSnippet && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={() => setShowSnippet(false)}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 560, maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Tracking Snippet</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}>
Sitendeki <code style={{ background: 'rgba(0,0,0,.3)', padding: '2px 6px', borderRadius: 4, fontFamily: 'monospace' }}>&lt;head&gt;</code> tagının içine ekle:
</div>
<pre style={{ background: 'rgba(0,0,0,.4)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 16px', fontFamily: 'monospace', fontSize: 12, color: 'var(--accent)', overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all', marginBottom: 16 }}>
{snippet}
</pre>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8, fontFamily: 'monospace' }}>
<span style={{ color: 'var(--green)' }}>data-domain</span> hangi domain olduğunu belirtir, değiştirme
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20, fontFamily: 'monospace' }}>
<span style={{ color: 'var(--green)' }}>defer</span> sayfayı yavaşlatmaz, arka planda yüklenir
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={() => { navigator.clipboard.writeText(snippet); }}
style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
Kopyala
</button>
<button onClick={() => setShowSnippet(false)}
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
Kapat
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,275 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
type Db = { id: string; name: string; host: string; port: number; database: string; username: string; color: string; ssl: boolean }
type Table = { table_name: string; row_count: number; size: string }
type QueryResult = { rows: Record<string, unknown>[]; fields: string[]; error: string | null }
export default function DatabasesPage() {
const [dbs, setDbs] = useState<Db[]>([])
const [selected, setSelected] = useState<Db | null>(null)
const [tab, setTab] = useState<'stats' | 'tables' | 'query'>('tables')
const [stats, setStats] = useState<Record<string, unknown> | null>(null)
const [tables, setTables] = useState<Table[]>([])
const [sql, setSql] = useState('SELECT * FROM ')
const [result, setResult] = useState<QueryResult | null>(null)
const [qLoading, setQLoading] = useState(false)
const [showAdd, setShowAdd] = useState(false)
const loadDbs = useCallback(async () => {
const r = await fetch('/api/config')
const cfg = await r.json()
setDbs(cfg.databases)
}, [])
useEffect(() => { loadDbs() }, [loadDbs])
const select = async (db: Db) => {
setSelected(db); setStats(null); setTables([]); setResult(null)
const [sr, tr] = await Promise.all([
fetch(`/api/db?dbId=${db.id}&action=stats`),
fetch(`/api/db?dbId=${db.id}&action=tables`),
])
if (sr.ok) setStats(await sr.json())
if (tr.ok) setTables(await tr.json())
}
const runQuery = async () => {
if (!selected) return
setQLoading(true)
const r = await fetch('/api/db', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dbId: selected.id, sql }) })
setResult(await r.json())
setQLoading(false)
}
const deleteDb = async (id: string) => {
if (!confirm('DB bağlantısını sil?')) return
await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'database', id }) })
if (selected?.id === id) setSelected(null)
loadDbs()
}
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
{/* LEFT */}
<div style={{ width: 240, background: 'var(--surface)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>Databases</span>
<button onClick={() => setShowAdd(true)} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '4px 10px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer' }}>+ Ekle</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
{dbs.length === 0
? <div style={{ padding: 20, color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace', textAlign: 'center' }}>Henüz DB yok</div>
: dbs.map(db => (
<div key={db.id} onClick={() => select(db)} style={{ padding: '10px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 4, background: selected?.id === db.id ? 'rgba(255,255,255,.04)' : 'transparent', border: `1px solid ${selected?.id === db.id ? 'var(--border)' : 'transparent'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: db.color, flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{db.name}</span>
<button onClick={e => { e.stopPropagation(); deleteDb(db.id) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer', flexShrink: 0 }}></button>
</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', paddingLeft: 16, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{db.host}/{db.database}
</div>
</div>
))}
</div>
</div>
{/* RIGHT */}
<div style={{ flex: 1, overflow: 'auto' }}>
{!selected
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}> DB seç</div>
: (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: selected.color }} />
<h2 style={{ fontSize: 18, fontWeight: 800 }}>{selected.name}</h2>
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>{selected.username}@{selected.host}:{selected.port}/{selected.database}</div>
</div>
{/* Stats row */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, marginBottom: 20 }}>
{[
{ l: 'Boyut', v: String(stats.size) },
{ l: 'Aktif Bağlantı', v: String(stats.active_connections) },
{ l: 'Tablo Sayısı', v: String(stats.table_count) },
].map(s => (
<div key={s.l} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }}>{s.l}</div>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--accent)' }}>{s.v}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 16, gap: 4 }}>
{(['tables', 'query', 'stats'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{ padding: '8px 16px', fontSize: 13, fontWeight: 600, color: tab === t ? 'var(--accent)' : 'var(--muted)', background: 'none', border: 'none', borderBottomWidth: 2, borderBottomStyle: 'solid', borderBottomColor: tab === t ? 'var(--accent)' : 'transparent', cursor: 'pointer', marginBottom: -1 }}>
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : 'İstatistik'}
</button>
))}
</div>
{/* Tables */}
{tab === 'tables' && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{tables.length === 0
? <div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Tablo bulunamadı veya yükleniyor...</div>
: <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>{['Tablo','Satır','Boyut'].map(h => <th key={h} style={{ padding: '9px 16px', fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textAlign: 'left', textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,.2)' }}>{h}</th>)}</tr></thead>
<tbody>
{tables.map(t => (
<tr key={t.table_name} style={{ cursor: 'pointer' }} onClick={() => { setTab('query'); setSql(`SELECT * FROM ${t.table_name} LIMIT 50`) }}>
<td style={{ padding: '10px 16px', fontSize: 13, fontFamily: 'monospace', color: 'var(--accent)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{t.table_name}</td>
<td style={{ padding: '10px 16px', fontSize: 12, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{Number(t.row_count).toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{t.size}</td>
</tr>
))}
</tbody>
</table>
}
</div>
)}
{/* Query */}
{tab === 'query' && (
<div>
<textarea value={sql} onChange={e => setSql(e.target.value)} rows={5}
onKeyDown={e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') runQuery() }}
style={{ width: '100%', background: 'rgba(0,0,0,.4)', border: '1px solid var(--border)', borderRadius: 8, padding: '12px 14px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', resize: 'vertical', outline: 'none', marginBottom: 8 }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Cmd+Enter · Sadece SELECT</span>
<button onClick={runQuery} disabled={qLoading} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 20px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
{qLoading ? '...' : '▶ Çalıştır'}
</button>
</div>
{result && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{result.error
? <div style={{ padding: 14, color: 'var(--red)', fontFamily: 'monospace', fontSize: 12 }}> {result.error}</div>
: <>
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--border)', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{result.rows.length} satır</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>{result.fields.map(f => <th key={f} style={{ padding: '8px 14px', fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textAlign: 'left', textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,.2)', whiteSpace: 'nowrap' }}>{f}</th>)}</tr></thead>
<tbody>
{result.rows.slice(0, 200).map((row, i) => (
<tr key={i}>
{result.fields.map(f => <td key={f} style={{ padding: '8px 14px', fontSize: 12, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)', whiteSpace: 'nowrap', color: row[f] == null ? 'var(--muted)' : 'var(--text)' }}>{row[f] == null ? 'NULL' : String(row[f])}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</>
}
</div>
)}
</div>
)}
{/* Stats detail */}
{tab === 'stats' && stats && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
<pre style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--muted)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(stats, null, 2)}</pre>
</div>
)}
</div>
)}
</div>
{showAdd && <AddDbModal onClose={() => setShowAdd(false)} onAdded={() => { loadDbs(); setShowAdd(false) }} />}
</div>
)
}
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 [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [connString, setConnString] = useState('')
const handleConnectionString = (val: string) => {
setConnString(val)
try {
const u = new URL(val)
if (u.protocol === 'postgres:' || u.protocol === 'postgresql:') {
setF(p => ({
...p,
host: u.hostname || p.host,
port: parseInt(u.port) || 5432,
database: u.pathname.slice(1) || p.database,
username: u.username || p.username,
password: decodeURIComponent(u.password) || p.password,
ssl: u.searchParams.get('sslmode') !== 'disable' && u.searchParams.get('sslmode') !== null ? true : p.ssl
}))
}
} catch {
// Geçersiz URL, yoksay
}
}
const submit = async () => {
setLoading(true); setError('')
// Test bağlantısı
try {
const testRes = await fetch('/api/db', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', db: { ...f, id: '__test__' } }) })
const testData = await testRes.json()
if (!testData.ok) {
setError('Bağlantı hatası: ' + (testData.error || 'Bilinmeyen hata'))
setLoading(false)
return
}
} catch (e) {
setError('Bağlantı test edilemedi.')
setLoading(false)
return
}
// Config'e kaydet
const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'database', item: f }) })
if (res.ok) { onAdded() } else { setError((await res.json()).error ?? 'Hata'); setLoading(false) }
}
const inp: React.CSSProperties = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none' }
const lbl: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 420 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 20 }}>Yeni DB Bağlantısı</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div><label style={lbl}>Bağlantı URL (Opsiyonel)</label><input style={inp} placeholder="postgres://user:pass@host:5432/db" value={connString} onChange={e => handleConnectionString(e.target.value)} /></div>
<div><label style={lbl}>Ad</label><input style={inp} placeholder="Kotekli Prod" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px', gap: 8 }}>
<div><label style={lbl}>Host</label><input style={inp} value={f.host} onChange={e => setF(p => ({ ...p, host: e.target.value }))} /></div>
<div><label style={lbl}>Port</label><input style={inp} type="number" value={f.port} onChange={e => setF(p => ({ ...p, port: parseInt(e.target.value) }))} /></div>
</div>
<div><label style={lbl}>Database</label><input style={inp} placeholder="mydb" value={f.database} onChange={e => setF(p => ({ ...p, database: e.target.value }))} /></div>
<div><label style={lbl}>Username</label><input style={inp} value={f.username} onChange={e => setF(p => ({ ...p, username: e.target.value }))} /></div>
<div><label style={lbl}>Password</label><input type="password" style={inp} value={f.password} onChange={e => setF(p => ({ ...p, password: e.target.value }))} /></div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--muted)', cursor: 'pointer' }}>
<input type="checkbox" checked={f.ssl} onChange={e => setF(p => ({ ...p, ssl: e.target.checked }))} /> SSL
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>RENK</span>
<input type="color" value={f.color} onChange={e => setF(p => ({ ...p, color: e.target.value }))} style={{ width: 30, height: 26, borderRadius: 4, border: '1px solid var(--border)', cursor: 'pointer', background: 'transparent' }} />
</div>
</div>
</div>
{error && <div style={{ color: 'var(--red)', fontSize: 12, fontFamily: 'monospace', marginTop: 10 }}> {error}</div>}
<div style={{ display: 'flex', gap: 10, marginTop: 22 }}>
<button onClick={onClose} style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>İptal</button>
<button onClick={submit} disabled={loading || !f.name || !f.database} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
{loading ? 'Kaydediliyor...' : 'Kaydet'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { isAuthenticated } from '@/lib/auth'
import { redirect } from 'next/navigation'
import Sidebar from '@/components/Sidebar'
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
if (!(await isAuthenticated())) redirect('/')
return (
<div style={{ display: 'flex', minHeight: '100vh', position: 'relative', zIndex: 1 }}>
<Sidebar />
<main style={{ flex: 1, overflow: 'auto', minWidth: 0 }}>{children}</main>
</div>
)
}

108
src/app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,108 @@
'use client'
import { useEffect, useState } from 'react'
type Config = {
sites: { id: string; name: string; url: string }[]
databases: { id: string; name: string; host: string; color: string }[]
services: { id: string; name: string; url: string; icon: string }[]
}
type SiteStatus = { id: string; status: string | null; ms: number | null; uptime24: number }
export default function OverviewPage() {
const [config, setConfig] = useState<Config | null>(null)
const [statuses, setStatuses] = useState<Record<string, SiteStatus>>({})
useEffect(() => {
fetch('/api/config').then(r => r.json()).then(async (cfg: Config) => {
setConfig(cfg)
// Her site için son durumu çek
const results = await Promise.all(
cfg.sites.map(s =>
fetch(`/api/ping?siteId=${s.id}`).then(r => r.json()).then(d => ({
id: s.id, status: d.last?.status ?? null, ms: d.last?.ms ?? null, uptime24: d.uptime24
}))
)
)
const map: Record<string, SiteStatus> = {}
results.forEach(r => { map[r.id] = r })
setStatuses(map)
})
}, [])
if (!config) return <div style={{ padding: 28, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}>Yükleniyor...</div>
const statusColor = (s: string | null) => s === 'up' ? 'var(--green)' : s === 'down' ? 'var(--red)' : s === 'degraded' ? 'var(--yellow)' : 'var(--muted)'
return (
<div style={{ padding: 28 }}>
<div style={{ marginBottom: 28 }}>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Overview</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>
{config.sites.length} site · {config.databases.length} DB · {config.services.length} servis
</p>
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14, marginBottom: 28 }}>
{[
{ label: 'Siteler', value: `${config.sites.filter(s => statuses[s.id]?.status === 'up').length}/${config.sites.length}`, sub: 'online', color: 'var(--green)' },
{ label: 'Databases', value: String(config.databases.length), sub: 'kayıtlı', color: 'var(--accent)' },
{ label: 'Servisler', value: String(config.services.length), sub: 'tanımlı', color: 'var(--purple)' },
].map(s => (
<div key={s.label} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '18px 20px' }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>{s.label}</div>
<div style={{ fontSize: 28, fontWeight: 800, color: s.color, letterSpacing: -1 }}>{s.value}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>{s.sub}</div>
</div>
))}
</div>
{/* Site statuses */}
{config.sites.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10 }}>Site Durumu</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{config.sites.map((site, i) => {
const st = statuses[site.id]
return (
<div key={site.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 18px', borderBottom: i < config.sites.length - 1 ? '1px solid var(--border)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor(st?.status ?? null), boxShadow: `0 0 6px ${statusColor(st?.status ?? null)}`, animation: st?.status === 'up' ? 'pulse 3s infinite' : 'none' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{site.name}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{site.url}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{st?.ms && <span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)' }}>{st.ms}ms</span>}
{st?.uptime24 !== undefined && <span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--green)' }}>{st.uptime24}%</span>}
<span style={{ fontSize: 11, fontFamily: 'monospace', fontWeight: 700, color: statusColor(st?.status ?? null) }}>
{st?.status?.toUpperCase() ?? '—'}
</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Quick nav */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12 }}>
{[
{ href: '/dashboard/uptime', label: 'Uptime →', desc: 'Site ekle, ping at, log gör', color: 'var(--green)' },
{ href: '/dashboard/databases', label: 'Databases →', desc: 'Bağlan, tablo gör, query çalıştır', color: 'var(--accent)' },
{ href: '/dashboard/services', label: 'Servisler →', desc: 'Coolify, Grafana vs. bağlantıları', color: 'var(--purple)' },
].map(item => (
<a key={item.href} href={item.href} style={{ display: 'block', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px', textDecoration: 'none', color: 'var(--text)', transition: 'border-color .2s' }}
onMouseOver={e => (e.currentTarget.style.borderColor = item.color)}
onMouseOut={e => (e.currentTarget.style.borderColor = 'var(--border)')}>
<div style={{ fontSize: 14, fontWeight: 700, color: item.color, marginBottom: 4 }}>{item.label}</div>
<div style={{ fontSize: 12, color: 'var(--muted)' }}>{item.desc}</div>
</a>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,125 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
type Service = { id: string; name: string; url: string; icon: string; description: string }
export default function ServicesPage() {
const [services, setServices] = useState<Service[]>([])
const [showAdd, setShowAdd] = useState(false)
const load = useCallback(async () => {
const r = await fetch('/api/config')
const cfg = await r.json()
setServices(cfg.services)
}, [])
useEffect(() => { load() }, [load])
const del = async (id: string) => {
if (!confirm('Sil?')) return
await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'service', id }) })
load()
}
return (
<div style={{ padding: 28 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, letterSpacing: -.5, marginBottom: 4 }}>Servisler</h1>
<p style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>Coolify, Grafana, n8n vs. hızlı erişim linkleri</p>
</div>
<button onClick={() => setShowAdd(true)} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: '9px 18px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>+ Servis Ekle</button>
</div>
{services.length === 0
? (
<div style={{ background: 'var(--surface)', border: '1px dashed var(--border)', borderRadius: 12, padding: '40px', textAlign: 'center', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}>
Henüz servis eklenmedi.<br />
<span style={{ color: 'var(--accent)', cursor: 'pointer' }} onClick={() => setShowAdd(true)}>+ Ekle</span> ile Coolify, Grafana gibi araçlara hızlı erişim ekle.
</div>
)
: (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
{services.map(svc => (
<div key={svc.id} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, padding: '20px', position: 'relative', transition: 'border-color .2s' }}
onMouseOver={e => (e.currentTarget.style.borderColor = 'rgba(255,255,255,.15)')}
onMouseOut={e => (e.currentTarget.style.borderColor = 'var(--border)')}>
<button onClick={() => del(svc.id)} style={{ position: 'absolute', top: 12, right: 12, background: 'none', border: 'none', color: 'var(--muted)', fontSize: 12, cursor: 'pointer', opacity: .5 }}
onMouseOver={e => (e.currentTarget.style.opacity = '1')}
onMouseOut={e => (e.currentTarget.style.opacity = '.5')}></button>
<div style={{ fontSize: 28, marginBottom: 10 }}>{svc.icon}</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 4 }}>{svc.name}</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 14, lineHeight: 1.4 }}>{svc.description}</div>
<a href={svc.url} target="_blank" rel="noreferrer"
style={{ display: 'inline-block', background: 'rgba(0,229,255,.08)', border: '1px solid rgba(0,229,255,.2)', borderRadius: 6, padding: '6px 14px', color: 'var(--accent)', fontSize: 12, textDecoration: 'none', fontFamily: 'monospace' }}>
</a>
</div>
))}
</div>
)
}
{showAdd && <AddServiceModal onClose={() => setShowAdd(false)} onAdded={() => { load(); setShowAdd(false) }} />}
</div>
)
}
function AddServiceModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
const [f, setF] = useState({ name: '', url: 'http://', icon: '🔧', description: '' })
const [loading, setLoading] = useState(false)
const submit = async () => {
setLoading(true)
await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'service', item: f }) })
setLoading(false)
onAdded()
}
const inp: React.CSSProperties = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none' }
const lbl: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }
const presets = [
{ name: 'Coolify', url: 'http://localhost:8000', icon: '⚙️', description: 'Deploy yönetimi' },
{ name: 'Grafana', url: 'http://localhost:3000', icon: '📊', description: 'Metrik görselleştirme' },
{ name: 'n8n', url: 'http://localhost:5678', icon: '🔄', description: 'Workflow otomasyonu' },
{ name: 'Adminer', url: 'http://localhost:8080', icon: '🗄️', description: 'DB yönetim arayüzü' },
]
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 420 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>Servis Ekle</div>
{/* Presets */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Hızlı Ekle</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{presets.map(p => (
<button key={p.name} onClick={() => setF(p)}
style={{ background: 'rgba(255,255,255,.04)', border: '1px solid var(--border)', borderRadius: 6, padding: '5px 10px', color: 'var(--text)', fontSize: 12, cursor: 'pointer' }}>
{p.icon} {p.name}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '50px 1fr', gap: 8 }}>
<div><label style={lbl}>İkon</label><input style={inp} value={f.icon} onChange={e => setF(p => ({ ...p, icon: e.target.value }))} /></div>
<div><label style={lbl}>Ad</label><input style={inp} placeholder="Coolify" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} /></div>
</div>
<div><label style={lbl}>URL</label><input style={inp} value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} /></div>
<div><label style={lbl}>ıklama</label><input style={inp} placeholder="Deploy yönetimi" value={f.description} onChange={e => setF(p => ({ ...p, description: e.target.value }))} /></div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 22 }}>
<button onClick={onClose} style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>İptal</button>
<button onClick={submit} disabled={loading || !f.name} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
{loading ? '...' : 'Ekle'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,202 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
type Site = { id: string; name: string; url: string; interval_min: number }
type Log = { status: string; ms: number | null; code: number | null; error: string | null; ts: number }
type SiteData = { logs: Log[]; uptime24: number; uptime7d: number; last: Log | null }
const sc = (s: string | null) => s === 'up' ? 'var(--green)' : s === 'down' ? 'var(--red)' : s === 'degraded' ? 'var(--yellow)' : 'var(--muted)'
export default function UptimePage() {
const [sites, setSites] = useState<Site[]>([])
const [selected, setSelected] = useState<Site | null>(null)
const [data, setData] = useState<SiteData | null>(null)
const [pinging, setPinging] = useState(false)
const [showAdd, setShowAdd] = useState(false)
const [editingSite, setEditingSite] = useState<Site | null>(null)
const loadSites = useCallback(async () => {
const r = await fetch('/api/config')
const cfg = await r.json()
setSites(cfg.sites)
}, [])
useEffect(() => { loadSites() }, [loadSites])
const selectSite = async (site: Site) => {
setSelected(site); setData(null)
const r = await fetch(`/api/ping?siteId=${site.id}`)
setData(await r.json())
}
const ping = async () => {
if (!selected) return
setPinging(true)
await fetch('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteId: selected.id }) })
const r = await fetch(`/api/ping?siteId=${selected.id}`)
setData(await r.json())
await loadSites()
setPinging(false)
}
const deleteSite = async (id: string) => {
if (!confirm('Siteyi silmek istiyor musun?')) return
await fetch('/api/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'site', id }) })
if (selected?.id === id) setSelected(null)
loadSites()
}
const last50 = (data?.logs ?? []).slice(0, 50).reverse()
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
{/* LEFT */}
<div style={{ width: 260, background: 'var(--surface)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontFamily: 'monospace', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>Uptime</span>
<button onClick={() => setShowAdd(true)} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '4px 10px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer' }}>+ Ekle</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
{sites.length === 0
? <div style={{ padding: 20, color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace', textAlign: 'center' }}>Henüz site yok</div>
: sites.map(site => (
<div key={site.id} onClick={() => selectSite(site)} style={{ padding: '10px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 4, background: selected?.id === site.id ? 'rgba(255,255,255,.04)' : 'transparent', border: `1px solid ${selected?.id === site.id ? 'var(--border)' : 'transparent'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>{site.name}</span>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={e => { e.stopPropagation(); setEditingSite(site) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer' }}></button>
<button onClick={e => { e.stopPropagation(); deleteSite(site.id) }} style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 11, cursor: 'pointer' }}></button>
</div>
</div>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site.url}</div>
</div>
))}
</div>
</div>
{/* RIGHT */}
<div style={{ flex: 1, overflow: 'auto' }}>
{!selected
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--muted)', fontFamily: 'monospace', fontSize: 13 }}> Site seç</div>
: (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<div style={{ width: 9, height: 9, borderRadius: '50%', background: sc(data?.last?.status ?? null), boxShadow: `0 0 8px ${sc(data?.last?.status ?? null)}` }} />
<h2 style={{ fontSize: 18, fontWeight: 800 }}>{selected.name}</h2>
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace' }}>{selected.url}</div>
</div>
<button onClick={ping} disabled={pinging} style={{ background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: '8px 18px', color: 'var(--accent)', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
{pinging ? '...' : '▶ Ping At'}
</button>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12, marginBottom: 24 }}>
{[
{ l: 'Son Durum', v: data?.last?.status?.toUpperCase() ?? '—', c: sc(data?.last?.status ?? null) },
{ l: 'Response', v: data?.last?.ms ? `${data.last.ms}ms` : '—', c: 'var(--text)' },
{ l: 'Uptime 24s', v: data ? `${data.uptime24}%` : '—', c: 'var(--green)' },
{ l: 'Uptime 7g', v: data ? `${data.uptime7d}%` : '—', c: 'var(--green)' },
].map(s => (
<div key={s.l} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }}>{s.l}</div>
<div style={{ fontSize: 20, fontWeight: 800, color: s.c }}>{s.v}</div>
</div>
))}
</div>
{/* Status bar */}
{last50.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Son {last50.length} kontrol</div>
<div style={{ display: 'flex', gap: 2, height: 28 }}>
{last50.map((log, i) => (
<div key={i} title={`${log.status}${log.ms}ms • ${new Date(log.ts * 1000).toLocaleString('tr-TR')}`}
style={{ flex: 1, borderRadius: 2, background: sc(log.status), opacity: .5 + (i / last50.length) * .5, cursor: 'help' }} />
))}
</div>
</div>
)}
{/* Log table */}
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>Log</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{!data ? (
<div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Yükleniyor...</div>
) : data.logs.length === 0 ? (
<div style={{ padding: 16, color: 'var(--muted)', fontFamily: 'monospace', fontSize: 12 }}>Henüz log yok Ping At butonuna bas</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>{['Zaman','Durum','MS','HTTP','Hata'].map(h => <th key={h} style={{ padding: '9px 16px', fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', textAlign: 'left', textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,.2)' }}>{h}</th>)}</tr>
</thead>
<tbody>
{data.logs.slice(0, 50).map((log, i) => (
<tr key={i}>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', color: 'var(--muted)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{new Date(log.ts * 1000).toLocaleString('tr-TR')}</td>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', color: sc(log.status), fontWeight: 700, borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.status?.toUpperCase()}</td>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.ms ?? '—'}</td>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.code ?? '—'}</td>
<td style={{ padding: '9px 16px', fontSize: 11, fontFamily: 'monospace', color: 'var(--red)', borderBottom: '1px solid rgba(255,255,255,.03)' }}>{log.error ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
{showAdd && <AddSiteModal onClose={() => setShowAdd(false)} onAdded={() => { loadSites(); setShowAdd(false) }} />}
{editingSite && <AddSiteModal site={editingSite} onClose={() => setEditingSite(null)} onAdded={() => { loadSites(); setEditingSite(null); if (selected?.id === editingSite.id) selectSite(editingSite) }} />}
</div>
)
}
function AddSiteModal({ site, onClose, onAdded }: { site?: Site; onClose: () => void; onAdded: () => void }) {
const [f, setF] = useState({ name: site?.name || '', url: site?.url || 'https://', interval_min: site?.interval_min || 5 })
const [loading, setLoading] = useState(false)
const submit = async () => {
setLoading(true)
const method = site ? 'PUT' : 'POST'
const body = site ? { type: 'site', id: site.id, item: f } : { type: 'site', item: f }
const res = await fetch('/api/config', { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (res.ok) {
if (!site) {
// Yeni site ise hemen ilk pingi at
const newSite = await res.json()
await fetch('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteId: newSite.id }) })
}
onAdded()
}
setLoading(false)
}
const inp: React.CSSProperties = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none' }
const lbl: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 }
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={onClose}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 400 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 20 }}>{site ? 'Site Düzenle' : 'Yeni Site Ekle'}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div><label style={lbl}>Ad</label><input style={inp} placeholder="Kotekli Asistan" value={f.name} onChange={e => setF(p => ({ ...p, name: e.target.value }))} /></div>
<div><label style={lbl}>URL</label><input style={inp} placeholder="https://example.com" value={f.url} onChange={e => setF(p => ({ ...p, url: e.target.value }))} /></div>
<div><label style={lbl}>Kontrol Aralığı (dk)</label><input style={inp} type="number" value={f.interval_min} onChange={e => setF(p => ({ ...p, interval_min: parseInt(e.target.value) }))} /></div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 24 }}>
<button onClick={onClose} style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>İptal</button>
<button onClick={submit} disabled={loading || !f.name || !f.url} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 8, padding: 10, color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
{loading ? 'Kaydediliyor...' : site ? 'Kaydet' : 'Ekle & Ping At'}
</button>
</div>
</div>
</div>
)
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

44
src/app/globals.css Normal file
View File

@@ -0,0 +1,44 @@
@import "tailwindcss";
:root {
--bg: #0d0f14;
--surface: #13161f;
--surface2: #1a1f2e;
--border: rgba(255,255,255,0.07);
--accent: #00e5ff;
--green: #00e676;
--yellow: #ffab00;
--red: #ff3d71;
--purple: #7b61ff;
--text: #e2e8f0;
--muted: #4a5270;
}
* { box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0,229,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,229,255,0.02) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a3050; border-radius: 2px; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
@keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
.fade-up { animation: fadeUp .25s ease forwards; }

17
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,17 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = { title: 'VPS Panel', description: 'Self-hosted ops panel' }
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="tr">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>{children}</body>
</html>
)
}

41
src/app/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const router = useRouter()
const [pw, setPw] = useState('')
const [err, setErr] = useState('')
const [loading, setLoading] = useState(false)
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true); setErr('')
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) })
if (res.ok) router.push('/dashboard')
else { setErr((await res.json()).error ?? 'Hata'); setLoading(false) }
}
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 16, padding: '40px 36px', width: 360 }}>
<div style={{ marginBottom: 32 }}>
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--accent)', letterSpacing: -0.5 }}>VPS Panel</div>
<div style={{ fontSize: 12, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 4 }}>self-hosted ops dashboard</div>
</div>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<input
type="password" value={pw} onChange={e => setPw(e.target.value)}
placeholder="Şifre" autoFocus
style={{ background: 'rgba(0,0,0,.3)', border: `1px solid ${err ? 'var(--red)' : 'var(--border)'}`, borderRadius: 8, padding: '11px 14px', color: 'var(--text)', fontSize: 14, fontFamily: 'monospace', outline: 'none', width: '100%' }}
/>
{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>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
const nav = [
{ href: '/dashboard', label: 'Overview', emoji: '⊞' },
{ href: '/dashboard/uptime', label: 'Uptime', emoji: '◎' },
{ href: '/dashboard/databases', label: 'Databases', emoji: '⊙' },
{ href: '/dashboard/analytics', label: 'Analytics', emoji: '◈' },
{ href: '/dashboard/services', label: 'Servisler', emoji: '⊛' },
]
export default function Sidebar() {
const path = usePathname()
const router = useRouter()
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/')
}
return (
<nav style={{ width: 210, background: 'var(--surface)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', padding: '20px 0', position: 'sticky', top: 0, height: '100vh', flexShrink: 0 }}>
<div style={{ padding: '0 16px 20px', borderBottom: '1px solid var(--border)', marginBottom: 12 }}>
<div style={{ fontSize: 16, fontWeight: 800, color: 'var(--accent)' }}>VPS Panel</div>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace', marginTop: 2, letterSpacing: 1 }}>OPS DASHBOARD</div>
</div>
<div style={{ flex: 1 }}>
{nav.map(item => {
const active = item.href === '/dashboard' ? path === item.href : path.startsWith(item.href)
return (
<a key={item.href} href={item.href} style={{
display: 'flex', alignItems: 'center', gap: 9,
padding: '9px 16px', fontSize: 13, fontWeight: 600,
color: active ? 'var(--accent)' : 'var(--muted)',
borderLeft: `2px solid ${active ? 'var(--accent)' : 'transparent'}`,
background: active ? 'rgba(0,229,255,.05)' : 'transparent',
textDecoration: 'none', transition: 'all .15s',
}}>
<span style={{ fontSize: 15 }}>{item.emoji}</span>
{item.label}
</a>
)
})}
</div>
<div style={{ padding: '16px', borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--green)', display: 'inline-block', boxShadow: '0 0 6px var(--green)', animation: 'pulse 2s infinite' }} />
aktif
</div>
<button onClick={logout} style={{ width: '100%', background: 'transparent', border: '1px solid var(--border)', borderRadius: 6, padding: '7px', color: 'var(--muted)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace', transition: 'all .2s' }}
onMouseOver={e => { const t = e.currentTarget; t.style.color = 'var(--red)'; t.style.borderColor = 'var(--red)' }}
onMouseOut={e => { const t = e.currentTarget; t.style.color = 'var(--muted)'; t.style.borderColor = 'var(--border)' }}>
Çıkış
</button>
</div>
</nav>
)
}

42
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,42 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const g = global as any
if (!g._uptimePingerStarted) {
g._uptimePingerStarted = true
// Dynamically import inside register
const { readConfig } = await import('./lib/config')
const { pingAndLog } = await import('./lib/ping')
// Bellek içi son ping zamanlarını tutacak obje
const lastPingMap: Record<string, number> = {}
// Döngü her dakika çalışıp süresi gelenleri kontrol edecek
setInterval(async () => {
try {
const config = readConfig()
if (!config.sites || config.sites.length === 0) return
const now = Date.now()
const toPing = config.sites.filter(site => {
const intervalMs = (site.interval_min || 5) * 60 * 1000
const last = lastPingMap[site.id] || 0
return (now - last) >= intervalMs
})
if (toPing.length > 0) {
await Promise.all(toPing.map(async site => {
lastPingMap[site.id] = now
await pingAndLog(site)
}))
console.log(`[AutoPing] ${toPing.length} site pinglendi (Ayar: site özel).`)
}
} catch (e) {
console.error('[AutoPing] Çalışırken hata oluştu:', e)
}
}, 60 * 1000) // 1 dakikada bir kontrol
console.log(`[AutoPing] Arka plan ping servisi başlatıldı (Her 1 dakikada bir sitelerin özel süreleri kontrol edilecek)`)
}
}
}

174
src/lib/appDb.ts Normal file
View File

@@ -0,0 +1,174 @@
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
// Initialize tables
pool.query(`
CREATE TABLE IF NOT EXISTS ping_logs (
id SERIAL PRIMARY KEY,
site_id TEXT NOT NULL,
status TEXT NOT NULL,
ms INTEGER,
code INTEGER,
error TEXT,
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE INDEX IF NOT EXISTS idx_ping_site ON ping_logs(site_id, ts DESC);
CREATE TABLE IF NOT EXISTS pageviews (
id SERIAL PRIMARY KEY,
domain TEXT NOT NULL,
path TEXT NOT NULL,
referrer TEXT,
country TEXT,
city TEXT,
ua TEXT,
device TEXT,
browser TEXT,
os TEXT,
visitor_id TEXT NOT NULL,
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE INDEX IF NOT EXISTS idx_pv_domain ON pageviews(domain, ts DESC);
CREATE INDEX IF NOT EXISTS idx_pv_ts ON pageviews(ts DESC);
`).catch(console.error)
export type PingLog = {
id: number
site_id: string
status: 'up' | 'down' | 'degraded'
ms: number | null
code: number | null
error: string | null
ts: number
}
export async function insertPingLog(log: Omit<PingLog, 'id' | 'ts'>) {
await pool.query(
`INSERT INTO ping_logs (site_id, status, ms, code, error) VALUES ($1, $2, $3, $4, $5)`,
[log.site_id, log.status, log.ms, log.code, log.error]
)
}
export async function getRecentLogs(siteId: string, limit = 100): Promise<PingLog[]> {
const res = await pool.query(
`SELECT * FROM ping_logs WHERE site_id = $1 ORDER BY ts DESC LIMIT $2`,
[siteId, limit]
)
return res.rows.map(r => ({ ...r, ms: Number(r.ms), code: Number(r.code), ts: Number(r.ts) }))
}
export async function getUptimePercent(siteId: string, hours = 24): Promise<number> {
const since = Math.floor(Date.now() / 1000) - hours * 3600
const res = await pool.query(
`SELECT status FROM ping_logs WHERE site_id = $1 AND ts > $2`,
[siteId, since]
)
const rows = res.rows
if (rows.length === 0) return 100
const up = rows.filter(r => r.status === 'up').length
return Math.round((up / rows.length) * 1000) / 10
}
export async function getLastLog(siteId: string): Promise<PingLog | null> {
const res = await pool.query(
`SELECT * FROM ping_logs WHERE site_id = $1 ORDER BY ts DESC LIMIT 1`,
[siteId]
)
if (res.rows.length === 0) return null
const r = res.rows[0]
return { ...r, ms: Number(r.ms), code: Number(r.code), ts: Number(r.ts) }
}
export type Pageview = {
id: number
domain: string
path: string
referrer: string | null
country: string | null
city: string | null
ua: string | null
device: string | null
browser: string | null
os: string | null
visitor_id: string
ts: number
}
export async function insertPageview(pv: Omit<Pageview, 'id' | 'ts'>) {
await pool.query(
`INSERT INTO pageviews (domain, path, referrer, country, city, ua, device, browser, os, visitor_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[pv.domain, pv.path, pv.referrer, pv.country, pv.city, pv.ua, pv.device, pv.browser, pv.os, pv.visitor_id]
)
}
export async function getAnalyticsStats(domain: string, days = 30) {
const since = Math.floor(Date.now() / 1000) - days * 86400
const totalRes = await pool.query(`SELECT COUNT(*) as n FROM pageviews WHERE domain = $1 AND ts > $2`, [domain, since])
const total = Number(totalRes.rows[0].n)
const uniqueRes = await pool.query(`SELECT COUNT(DISTINCT visitor_id) as n FROM pageviews WHERE domain = $1 AND ts > $2`, [domain, since])
const unique = Number(uniqueRes.rows[0].n)
const topPagesRes = await pool.query(`
SELECT path, COUNT(*) as views
FROM pageviews WHERE domain = $1 AND ts > $2
GROUP BY path ORDER BY views DESC LIMIT 10
`, [domain, since])
const topPages = topPagesRes.rows.map(r => ({ path: r.path, views: Number(r.views) }))
const topReferrersRes = await pool.query(`
SELECT referrer, COUNT(*) as visits
FROM pageviews WHERE domain = $1 AND ts > $2 AND referrer IS NOT NULL AND referrer != ''
GROUP BY referrer ORDER BY visits DESC LIMIT 10
`, [domain, since])
const topReferrers = topReferrersRes.rows.map(r => ({ referrer: r.referrer, visits: Number(r.visits) }))
const byDeviceRes = await pool.query(`
SELECT device, COUNT(*) as n
FROM pageviews WHERE domain = $1 AND ts > $2 AND device IS NOT NULL
GROUP BY device ORDER BY n DESC
`, [domain, since])
const byDevice = byDeviceRes.rows.map(r => ({ device: r.device, n: Number(r.n) }))
const byBrowserRes = await pool.query(`
SELECT browser, COUNT(*) as n
FROM pageviews WHERE domain = $1 AND ts > $2 AND browser IS NOT NULL
GROUP BY browser ORDER BY n DESC LIMIT 6
`, [domain, since])
const byBrowser = byBrowserRes.rows.map(r => ({ browser: r.browser, n: Number(r.n) }))
const byOsRes = await pool.query(`
SELECT os, COUNT(*) as n
FROM pageviews WHERE domain = $1 AND ts > $2 AND os IS NOT NULL
GROUP BY os ORDER BY n DESC LIMIT 6
`, [domain, since])
const byOs = byOsRes.rows.map(r => ({ os: r.os, n: Number(r.n) }))
const byCountryRes = await pool.query(`
SELECT country, COUNT(*) as n
FROM pageviews WHERE domain = $1 AND ts > $2 AND country IS NOT NULL
GROUP BY country ORDER BY n DESC LIMIT 10
`, [domain, since])
const byCountry = byCountryRes.rows.map(r => ({ country: r.country, n: Number(r.n) }))
const dailyRes = await pool.query(`
SELECT to_char(to_timestamp(ts), 'YYYY-MM-DD') as day, COUNT(*) as views, COUNT(DISTINCT visitor_id) as visitors
FROM pageviews WHERE domain = $1 AND ts > $2
GROUP BY day ORDER BY day ASC
`, [domain, since])
const daily = dailyRes.rows.map(r => ({ day: r.day, views: Number(r.views), visitors: Number(r.visitors) }))
return { total, unique, topPages, topReferrers, byDevice, byBrowser, byOs, byCountry, daily }
}
export async function getAllDomains(): Promise<string[]> {
const res = await pool.query(`SELECT DISTINCT domain FROM pageviews ORDER BY domain`)
return res.rows.map(r => r.domain)
}
export default pool

35
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,35 @@
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
const COOKIE = 'panel_token'
function secret() {
return new TextEncoder().encode(process.env.JWT_SECRET ?? 'dev-secret-change-this')
}
export async function createToken() {
return new SignJWT({ r: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(secret())
}
export async function verifyToken(token: string) {
try { await jwtVerify(token, secret()); return true } catch { return false }
}
export async function isAuthenticated() {
const store = await cookies()
const token = store.get(COOKIE)?.value
return token ? verifyToken(token) : false
}
export async function requireAuth(req: NextRequest): Promise<NextResponse | null> {
const token = req.cookies.get(COOKIE)?.value
if (!token || !(await verifyToken(token)))
return NextResponse.json({ error: 'Yetkisiz' }, { status: 401 })
return null
}
export { COOKIE }

50
src/lib/config.ts Normal file
View File

@@ -0,0 +1,50 @@
import fs from 'fs'
import path from 'path'
const CONFIG_PATH = path.join(process.cwd(), 'config.json')
export interface Site {
id: string
name: string
url: string
interval_min: number
}
export interface Database {
id: string
name: string
host: string
port: number
database: string
username: string
password: string
ssl: boolean
color: string
}
export interface Service {
id: string
name: string
url: string
icon: string
description: string
}
export interface Config {
sites: Site[]
databases: Database[]
services: Service[]
}
export function readConfig(): Config {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
return JSON.parse(raw)
}
export function writeConfig(config: Config): void {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8')
}
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}

85
src/lib/db.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Pool } from 'pg'
import { Database } from './config'
const pools = new Map<string, Pool>()
function getPool(db: Database): Pool {
if (pools.has(db.id)) return pools.get(db.id)!
const pool = new Pool({
host: db.host, port: db.port, database: db.database,
user: db.username, password: db.password,
ssl: db.ssl ? { rejectUnauthorized: false } : false,
max: 3, connectionTimeoutMillis: 8000,
})
pools.set(db.id, pool)
return pool
}
export async function testDb(db: Database) {
const start = Date.now()
try {
const pool = db.id === '__test__' ? new Pool({
host: db.host, port: db.port, database: db.database,
user: db.username, password: db.password,
ssl: db.ssl ? { rejectUnauthorized: false } : false,
max: 1, connectionTimeoutMillis: 5000,
}) : getPool(db)
const client = await pool.connect()
const res = await client.query('SELECT version()')
client.release()
if (db.id === '__test__') await pool.end()
return { ok: true, ms: Date.now() - start, version: (res.rows[0].version as string).split(' ').slice(0, 2).join(' ') }
} catch (e: unknown) {
return { ok: false, ms: Date.now() - start, error: e instanceof Error ? e.message : 'Hata' }
}
}
export async function getDbStats(db: Database) {
const pool = getPool(db)
const client = await pool.connect()
try {
const [size, conns, tables] = await Promise.all([
client.query(`SELECT pg_size_pretty(pg_database_size(current_database())) AS size`),
client.query(`SELECT count(*) AS active FROM pg_stat_activity WHERE state='active'`),
client.query(`SELECT count(*) AS cnt FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'`),
])
return {
size: size.rows[0].size,
active_connections: conns.rows[0].active,
table_count: tables.rows[0].cnt,
}
} finally { client.release() }
}
export async function getTables(db: Database) {
const pool = getPool(db)
const client = await pool.connect()
try {
const res = await client.query(`
SELECT t.table_name,
c.reltuples::bigint AS row_count,
pg_size_pretty(pg_total_relation_size(quote_ident(t.table_name))) AS size
FROM information_schema.tables t
JOIN pg_class c ON c.relname = t.table_name
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
ORDER BY pg_total_relation_size(quote_ident(t.table_name)) DESC
`)
return res.rows
} finally { client.release() }
}
export async function runQuery(db: Database, sql: string) {
const trimmed = sql.trim().toLowerCase()
const blocked = ['drop','delete','truncate','alter','create','insert','update','grant','revoke']
if (blocked.some(k => trimmed.startsWith(k)))
return { rows: [], fields: [], error: 'Sadece SELECT sorguları çalıştırılabilir.' }
const pool = getPool(db)
const client = await pool.connect()
try {
const res = await client.query(sql)
return { rows: res.rows, fields: res.fields.map(f => f.name), error: null }
} catch (e: unknown) {
return { rows: [], fields: [], error: e instanceof Error ? e.message : 'Sorgu hatası' }
} finally { client.release() }
}

35
src/lib/ping.ts Normal file
View File

@@ -0,0 +1,35 @@
import { Site } from './config'
import { insertPingLog } from './appDb'
export async function pingSite(site: Site): Promise<{
status: 'up' | 'down' | 'degraded'
ms: number
code: number | null
error: string | null
}> {
const start = Date.now()
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 10000)
try {
const res = await fetch(site.url, {
signal: controller.signal,
headers: { 'User-Agent': 'VPSPanel/1.0' },
})
clearTimeout(timer)
const ms = Date.now() - start
const status = res.ok ? (ms > 3000 ? 'degraded' : 'up') : 'down'
return { status, ms, code: res.status, error: null }
} catch (e: unknown) {
clearTimeout(timer)
const ms = Date.now() - start
const isTimeout = e instanceof Error && e.name === 'AbortError'
return { status: 'down', ms, code: null, error: isTimeout ? 'Timeout' : (e instanceof Error ? e.message : 'Hata') }
}
}
export async function pingAndLog(site: Site) {
const result = await pingSite(site)
await insertPingLog({ site_id: site.id, ...result })
return result
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}