feat: database backups & analytics site management
- Added manual database backup functionality with pg_dump - Added cron-based automated backups to Google Drive and Dropbox - Added ability to manually add, edit, and delete sites in Analytics - Added 1-day timeframe filter in Analytics page - Updated Dockerfile to include postgresql-client
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth'
|
||||
import { getAnalyticsStats, getAllDomains } from '@/lib/appDb'
|
||||
import { getAnalyticsStats, getAllDomains, addAnalyticsSite, renameAnalyticsSite, deleteAnalyticsSite } from '@/lib/appDb'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const err = await requireAuth(req)
|
||||
@@ -18,3 +18,36 @@ export async function GET(req: NextRequest) {
|
||||
const stats = await getAnalyticsStats(domain, days)
|
||||
return NextResponse.json(stats)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const err = await requireAuth(req)
|
||||
if (err) return err
|
||||
|
||||
const { domain } = await req.json()
|
||||
if (!domain) return NextResponse.json({ error: 'Domain required' }, { status: 400 })
|
||||
|
||||
await addAnalyticsSite(domain)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const err = await requireAuth(req)
|
||||
if (err) return err
|
||||
|
||||
const { oldDomain, newDomain } = await req.json()
|
||||
if (!oldDomain || !newDomain) return NextResponse.json({ error: 'Missing params' }, { status: 400 })
|
||||
|
||||
await renameAnalyticsSite(oldDomain, newDomain)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const err = await requireAuth(req)
|
||||
if (err) return err
|
||||
|
||||
const domain = req.nextUrl.searchParams.get('domain')
|
||||
if (!domain) return NextResponse.json({ error: 'Domain required' }, { status: 400 })
|
||||
|
||||
await deleteAnalyticsSite(domain)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from '@/lib/appDb'
|
||||
import { generateId } from '@/lib/config'
|
||||
import { reloadBackupCrons } from '@/lib/cronWorker'
|
||||
|
||||
// GET configs and logs for a dbId
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const dbId = searchParams.get('dbId')
|
||||
if (!dbId) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
|
||||
|
||||
try {
|
||||
const configRes = await pool.query(`SELECT * FROM backup_configs WHERE db_id = $1 LIMIT 1`, [dbId])
|
||||
const logsRes = await pool.query(`SELECT * FROM backup_logs WHERE db_id = $1 ORDER BY ts DESC LIMIT 50`, [dbId])
|
||||
|
||||
return NextResponse.json({
|
||||
config: configRes.rows[0] || null,
|
||||
logs: logsRes.rows.map(r => ({ ...r, file_size: Number(r.file_size), ts: Number(r.ts) }))
|
||||
})
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update a backup config
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { db_id, schedule, cloud_type, credentials } = body
|
||||
|
||||
if (!db_id || !schedule || !cloud_type || !credentials) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if exists
|
||||
const existing = await pool.query(`SELECT id FROM backup_configs WHERE db_id = $1`, [db_id])
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await pool.query(
|
||||
`UPDATE backup_configs SET schedule=$1, cloud_type=$2, credentials=$3 WHERE db_id=$4`,
|
||||
[schedule, cloud_type, credentials, db_id]
|
||||
)
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO backup_configs (id, db_id, schedule, cloud_type, credentials) VALUES ($1, $2, $3, $4, $5)`,
|
||||
[generateId(), db_id, schedule, cloud_type, credentials]
|
||||
)
|
||||
}
|
||||
|
||||
// Reload crons in memory
|
||||
await reloadBackupCrons()
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Delete backup config
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { db_id } = body
|
||||
if (!db_id) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
|
||||
|
||||
await pool.query(`DELETE FROM backup_configs WHERE db_id = $1`, [db_id])
|
||||
await reloadBackupCrons()
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readConfig } from '@/lib/config'
|
||||
import { createPgDumpStream } from '@/lib/backup'
|
||||
|
||||
// GET /api/db/backup?dbId=...
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const dbId = searchParams.get('dbId')
|
||||
if (!dbId) return NextResponse.json({ error: 'Missing dbId' }, { status: 400 })
|
||||
|
||||
const config = await readConfig()
|
||||
const db = config.databases.find(d => d.id === dbId)
|
||||
if (!db) return NextResponse.json({ error: 'Database not found' }, { status: 404 })
|
||||
|
||||
try {
|
||||
const child = await createPgDumpStream(db)
|
||||
const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${db.name}_${dateStr}.sql`
|
||||
|
||||
// Stream the output of pg_dump to the response
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
child.stdout.on('data', (chunk) => controller.enqueue(chunk))
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
controller.close()
|
||||
} else {
|
||||
controller.error(new Error(`pg_dump exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
child.on('error', (err) => controller.error(err))
|
||||
},
|
||||
cancel() {
|
||||
child.kill()
|
||||
}
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/sql',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type Stats = {
|
||||
}
|
||||
|
||||
const DAYS_OPTIONS = [
|
||||
{ label: '1g', value: 1 },
|
||||
{ label: '7g', value: 7 },
|
||||
{ label: '30g', value: 30 },
|
||||
{ label: '90g', value: 90 },
|
||||
@@ -69,17 +70,30 @@ export default function AnalyticsPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSnippet, setShowSnippet] = useState(false)
|
||||
const [showAddSite, setShowAddSite] = useState(false)
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [panelUrl, setPanelUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setPanelUrl(window.location.origin)
|
||||
const loadDomains = useCallback(() => {
|
||||
fetch('/api/analytics/stats')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setDomains(d.domains ?? [])
|
||||
if (d.domains?.length > 0) setSelectedDomain(d.domains[0])
|
||||
const dms = d.domains ?? []
|
||||
setDomains(dms)
|
||||
if (dms.length > 0) {
|
||||
if (!selectedDomain || !dms.includes(selectedDomain)) {
|
||||
setSelectedDomain(dms[0])
|
||||
}
|
||||
} else {
|
||||
setSelectedDomain(null)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}, [selectedDomain])
|
||||
|
||||
useEffect(() => {
|
||||
setPanelUrl(window.location.origin)
|
||||
loadDomains()
|
||||
}, [loadDomains])
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!selectedDomain) return
|
||||
@@ -91,6 +105,34 @@ export default function AnalyticsPage() {
|
||||
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!selectedDomain) return
|
||||
const newName = window.prompt("Yeni alan adını girin (Örn: example.com):", selectedDomain)
|
||||
if (!newName || newName.trim() === '' || newName === selectedDomain) return
|
||||
|
||||
setLoading(true)
|
||||
await fetch('/api/analytics/stats', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ oldDomain: selectedDomain, newDomain: newName.trim() })
|
||||
})
|
||||
setSelectedDomain(newName.trim())
|
||||
|
||||
// Refresh domains after brief delay for state to sync
|
||||
setTimeout(loadDomains, 100)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedDomain) return
|
||||
if (!window.confirm(`'${selectedDomain}' sitesini ve tüm analiz verilerini SİLMEK istediğinize emin misiniz? Bu işlem geri alınamaz!`)) return
|
||||
|
||||
setLoading(true)
|
||||
await fetch(`/api/analytics/stats?domain=${encodeURIComponent(selectedDomain)}`, { method: 'DELETE' })
|
||||
setSelectedDomain(null)
|
||||
|
||||
setTimeout(loadDomains, 100)
|
||||
}
|
||||
|
||||
const snippet = `<!-- VPS Panel Analytics -->
|
||||
<script defer src="${panelUrl}/api/analytics/script" data-domain="${selectedDomain ?? 'senindomain.com'}"></script>`
|
||||
|
||||
@@ -103,6 +145,16 @@ export default function AnalyticsPage() {
|
||||
<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' }}>
|
||||
{selectedDomain && (
|
||||
<>
|
||||
<button onClick={handleRename} style={{ background: 'transparent', border: '1px solid var(--border)', borderRadius: 7, padding: '8px 14px', color: 'var(--text)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Düzenle</button>
|
||||
<button onClick={handleDelete} style={{ background: 'rgba(255,50,50,.1)', border: '1px solid rgba(255,50,50,.3)', borderRadius: 7, padding: '8px 14px', color: 'var(--red, #ff5c5c)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>Sil</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => setShowAddSite(true)}
|
||||
style={{ background: 'rgba(0,229,255,.12)', border: '1px solid rgba(0,229,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--accent)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
|
||||
+ Site Ekle
|
||||
</button>
|
||||
<button onClick={() => setShowSnippet(true)}
|
||||
style={{ background: 'rgba(123,97,255,.12)', border: '1px solid rgba(123,97,255,.35)', borderRadius: 7, padding: '8px 14px', color: 'var(--purple, #7b61ff)', fontSize: 12, cursor: 'pointer', fontFamily: 'monospace' }}>
|
||||
{'</>'} Snippet
|
||||
@@ -257,6 +309,64 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Site modal */}
|
||||
{showAddSite && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.75)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={() => setShowAddSite(false)}>
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', width: 400, maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>Yeni Site Ekle</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 20 }}>
|
||||
Takip etmek istediğiniz alan adını girin (örn: example.com)
|
||||
</div>
|
||||
|
||||
<input
|
||||
style={{ width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 20 }}
|
||||
placeholder="example.com"
|
||||
value={newDomain}
|
||||
onChange={e => setNewDomain(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newDomain.trim()) {
|
||||
fetch('/api/analytics/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: newDomain.trim() })
|
||||
}).then(() => {
|
||||
setNewDomain('');
|
||||
setShowAddSite(false);
|
||||
loadDomains();
|
||||
setSelectedDomain(newDomain.trim());
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
disabled={!newDomain.trim()}
|
||||
onClick={() => {
|
||||
fetch('/api/analytics/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: newDomain.trim() })
|
||||
}).then(() => {
|
||||
setNewDomain('');
|
||||
setShowAddSite(false);
|
||||
loadDomains();
|
||||
setSelectedDomain(newDomain.trim());
|
||||
})
|
||||
}}
|
||||
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 }}>
|
||||
Ekle
|
||||
</button>
|
||||
<button onClick={() => setShowAddSite(false)}
|
||||
style={{ flex: 1, background: 'transparent', border: '1px solid var(--border)', borderRadius: 8, padding: 10, color: 'var(--muted)', cursor: 'pointer', fontSize: 13 }}>
|
||||
İptal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ type QueryResult = { rows: Record<string, unknown>[]; fields: string[]; error: s
|
||||
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 [tab, setTab] = useState<'stats' | 'tables' | 'query' | 'backups'>('tables')
|
||||
const [stats, setStats] = useState<Record<string, unknown> | null>(null)
|
||||
const [tables, setTables] = useState<Table[]>([])
|
||||
const [sql, setSql] = useState('SELECT * FROM ')
|
||||
@@ -107,9 +107,9 @@ export default function DatabasesPage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 16, gap: 4 }}>
|
||||
{(['tables', 'query', 'stats'] as const).map(t => (
|
||||
{(['tables', 'query', 'stats', 'backups'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)} style={{ padding: '8px 16px', fontSize: 13, fontWeight: 600, color: tab === t ? 'var(--accent)' : 'var(--muted)', background: 'none', border: 'none', borderBottomWidth: 2, borderBottomStyle: 'solid', borderBottomColor: tab === t ? 'var(--accent)' : 'transparent', cursor: 'pointer', marginBottom: -1 }}>
|
||||
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : 'İstatistik'}
|
||||
{t === 'tables' ? 'Tablolar' : t === 'query' ? 'Query' : t === 'stats' ? 'İstatistik' : 'Yedekler'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -178,6 +178,9 @@ export default function DatabasesPage() {
|
||||
<pre style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--muted)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(stats, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backups */}
|
||||
{tab === 'backups' && <BackupTab db={selected} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,3 +276,108 @@ function AddDbModal({ onClose, onAdded }: { onClose: () => void; onAdded: () =>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BackupTab({ db }: { db: Db }) {
|
||||
const [config, setConfig] = useState<any>(null)
|
||||
const [logs, setLogs] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [f, setF] = useState({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const r = await fetch(`/api/backups?dbId=${db.id}`)
|
||||
if (r.ok) {
|
||||
const data = await r.json()
|
||||
setConfig(data.config)
|
||||
setLogs(data.logs)
|
||||
if (data.config) {
|
||||
setF({ schedule: data.config.schedule, cloud_type: data.config.cloud_type, credentials: data.config.credentials })
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}, [db.id])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true)
|
||||
await fetch('/api/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ db_id: db.id, ...f })
|
||||
})
|
||||
await load()
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const deleteConfig = async () => {
|
||||
if (!confirm('Otomatik yedeklemeyi kapat?')) return
|
||||
await fetch('/api/backups', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ db_id: db.id })
|
||||
})
|
||||
setConfig(null)
|
||||
setF({ schedule: '0 0 * * *', cloud_type: 'dropbox', credentials: '' })
|
||||
}
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Yükleniyor...</div>
|
||||
|
||||
const inp = { width: '100%', background: 'rgba(0,0,0,.3)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 12px', color: 'var(--text)', fontSize: 13, fontFamily: 'monospace', outline: 'none', marginBottom: 12 }
|
||||
const lbl = { display: 'block', fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6 } as any
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700 }}>Otomatik Yedekleme</h3>
|
||||
<a href={`/api/db/backup?dbId=${db.id}`} download style={{ background: 'rgba(255,255,255,.1)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 12px', color: 'var(--text)', fontSize: 12, textDecoration: 'none' }}>
|
||||
↓ Hemen İndir
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div><label style={lbl}>Cron Schedule (node-cron)</label><input style={inp} value={f.schedule} onChange={e => setF(p => ({ ...p, schedule: e.target.value }))} placeholder="0 0 * * *" /></div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={lbl}>Cloud Sağlayıcı</label>
|
||||
<select style={inp as any} value={f.cloud_type} onChange={e => setF(p => ({ ...p, cloud_type: e.target.value }))}>
|
||||
<option value="dropbox">Dropbox</option>
|
||||
<option value="gdrive">Google Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={lbl}>Credentials (JSON veya Token)</label>
|
||||
<textarea style={{ ...inp, resize: 'vertical' } as any} rows={4} value={f.credentials} onChange={e => setF(p => ({ ...p, credentials: e.target.value }))} placeholder={f.cloud_type === 'dropbox' ? 'Dropbox Access Token buraya...' : 'Google Service Account JSON buraya...'} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={saveConfig} disabled={saving} style={{ flex: 1, background: 'rgba(0,229,255,.1)', border: '1px solid rgba(0,229,255,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--accent)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>{saving ? 'Kaydediliyor...' : 'Kaydet'}</button>
|
||||
{config && (
|
||||
<button onClick={deleteConfig} style={{ background: 'rgba(255,0,0,.1)', border: '1px solid rgba(255,0,0,.3)', borderRadius: 6, padding: '8px 12px', color: 'var(--red)', cursor: 'pointer', fontSize: 13 }}>Kapat</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 340, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: 20 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700, marginBottom: 16 }}>Geçmiş İşlemler</h3>
|
||||
{logs.length === 0 ? <div style={{ color: 'var(--muted)', fontSize: 12, fontFamily: 'monospace' }}>Kayıt yok</div> : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{logs.map((log: any) => (
|
||||
<div key={log.id} style={{ padding: 12, background: 'rgba(0,0,0,.2)', borderRadius: 6, borderLeft: `3px solid ${log.status === 'success' ? '#00e676' : 'var(--red)'}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: log.status === 'success' ? '#00e676' : 'var(--red)' }}>{log.status.toUpperCase()}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>{new Date(log.ts * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
{log.status === 'success' && <div style={{ fontSize: 11, color: 'var(--muted)', fontFamily: 'monospace' }}>Boyut: {(log.file_size / 1024).toFixed(2)} KB</div>}
|
||||
{log.status !== 'success' && <div style={{ fontSize: 11, color: 'var(--red)', fontFamily: 'monospace', marginTop: 4 }}>{log.message}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user