feat: add insight page with Lighthouse analysis

- New /dashboard/insight page: URL analizi, mobil/masaüstü strateji seçimi
- Core Web Vitals, fırsatlar ve teşhis bölümleri
- PSI throttling değerleri ile eşleştirildi
- Dockerfile: Chromium eklendi (insight için), .dockerignore oluşturuldu
- serverExternalPackages: lighthouse ve chrome-launcher bundle dışına alındı

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mstfyldz
2026-06-06 01:37:13 +03:00
co-authored by Claude Sonnet 4.6
parent a4c58135df
commit 851ce96703
8 changed files with 2594 additions and 23 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.next
node_modules
npm-debug.log*
.env*.local
.dockerignore
README.md
+8 -2
View File
@@ -23,10 +23,17 @@ RUN npm run build
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache postgresql-client
RUN apk add --no-cache \
postgresql-client \
chromium \
freetype \
fontconfig \
ttf-freefont
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# chrome-launcher'ın Alpine'daki Chromium binary'sini bulması için
ENV CHROME_PATH=/usr/bin/chromium-browser
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -39,7 +46,6 @@ RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# config.json dosyasına yazma izni verebilmek için boş bir dosya oluşturup sahipliğini veriyoruz
RUN touch config.json && chown nextjs:nodejs config.json
USER nextjs
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
/* config options here */
serverExternalPackages: ['lighthouse', 'chrome-launcher'],
};
export default nextConfig;
+2099 -19
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,9 +10,11 @@
},
"dependencies": {
"@types/pg": "^8.20.0",
"chrome-launcher": "^1.2.1",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"jose": "^6.2.3",
"lighthouse": "^13.3.0",
"lucide-react": "^1.16.0",
"mysql2": "^3.22.4",
"next": "16.2.6",
+139
View File
@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import lighthouse from 'lighthouse'
import * as chromeLauncher from 'chrome-launcher'
export const runtime = 'nodejs'
const OPPORTUNITY_IDS = [
'render-blocking-resources',
'unused-css-rules',
'unused-javascript',
'uses-optimized-images',
'uses-webp-images',
'uses-text-compression',
'uses-responsive-images',
'offscreen-images',
'server-response-time',
'redirects',
'efficient-animated-content',
'legacy-javascript',
'unminified-css',
'unminified-javascript',
]
const DIAGNOSTIC_IDS = [
'total-byte-weight',
'dom-size',
'bootup-time',
'mainthread-work-breakdown',
'font-display',
'third-party-summary',
'critical-request-chains',
]
function pickAudit(audit: any) {
if (!audit) return null
return {
id: audit.id,
title: audit.title,
description: audit.description,
score: audit.score,
scoreDisplayMode: audit.scoreDisplayMode,
displayValue: audit.displayValue ?? null,
numericValue: audit.numericValue ?? null,
}
}
export async function GET(request: NextRequest) {
const authErr = await requireAuth(request)
if (authErr) return authErr
const { searchParams } = new URL(request.url)
const url = searchParams.get('url')
const strategy = searchParams.get('strategy') || 'mobile'
if (!url) {
return NextResponse.json({ error: 'URL gerekli' }, { status: 400 })
}
try {
new URL(url)
} catch {
return NextResponse.json({ error: 'Geçersiz URL formatı' }, { status: 400 })
}
let chrome: chromeLauncher.LaunchedChrome | undefined
try {
chrome = await chromeLauncher.launch({
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
})
const formFactor = strategy === 'desktop' ? 'desktop' : 'mobile'
const screenEmulation = formFactor === 'desktop'
? { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false }
: { mobile: true, width: 375, height: 812, deviceScaleFactor: 3, disabled: false }
// PSI ile eşleşen throttling değerleri
const throttling = formFactor === 'desktop'
? { rttMs: 40, throughputKbps: 10_240, uploadThroughputKbps: 10_240, cpuSlowdownMultiplier: 1 }
: { rttMs: 150, throughputKbps: 1_638.4, uploadThroughputKbps: 750, cpuSlowdownMultiplier: 4 }
const runnerResult = await Promise.race([
lighthouse(url, {
port: chrome.port,
output: 'json',
logLevel: 'error',
formFactor,
screenEmulation,
throttling,
throttlingMethod: 'simulate',
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Lighthouse zaman aşımına uğradı')), 60_000)
),
])
if (!runnerResult) throw new Error('Lighthouse sonuç döndürmedi')
const { lhr } = runnerResult
return NextResponse.json({
id: url,
lighthouseResult: {
categories: {
performance: { score: lhr.categories?.performance?.score ?? 0 },
accessibility: { score: lhr.categories?.accessibility?.score ?? 0 },
'best-practices': { score: lhr.categories?.['best-practices']?.score ?? 0 },
seo: { score: lhr.categories?.seo?.score ?? 0 },
},
audits: {
'first-contentful-paint': pickAudit(lhr.audits?.['first-contentful-paint']),
'largest-contentful-paint': pickAudit(lhr.audits?.['largest-contentful-paint']),
'total-blocking-time': pickAudit(lhr.audits?.['total-blocking-time']),
'cumulative-layout-shift': pickAudit(lhr.audits?.['cumulative-layout-shift']),
'speed-index': pickAudit(lhr.audits?.['speed-index']),
'interactive': pickAudit(lhr.audits?.['interactive']),
},
opportunities: OPPORTUNITY_IDS
.map(id => pickAudit(lhr.audits?.[id]))
.filter((a): a is NonNullable<typeof a> => a !== null && a.score !== null && a.score < 0.9),
diagnostics: DIAGNOSTIC_IDS
.map(id => pickAudit(lhr.audits?.[id]))
.filter((a): a is NonNullable<typeof a> => a !== null),
},
})
} catch (error: any) {
console.error('Lighthouse hatası:', error)
return NextResponse.json(
{ error: error.message.includes('zaman aşımı')
? 'Analiz zaman aşımına uğradı, lütfen tekrar deneyin.'
: 'Analiz sırasında hata oluştu. Sunucuda Chrome yüklü olduğundan emin olun.' },
{ status: 500 }
)
} finally {
await chrome?.kill()
}
}
+336
View File
@@ -0,0 +1,336 @@
'use client'
import React, { useState } from 'react'
import { Search, Monitor, Smartphone, AlertCircle, Gauge, Activity, Zap, CheckCircle2, XCircle, AlertTriangle, Info } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AuditItem {
id: string
title: string
description: string
score: number | null
scoreDisplayMode: string
displayValue: string | null
numericValue: number | null
}
function stripMarkdown(text: string) {
return text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim()
}
export default function InsightPage() {
const [url, setUrl] = useState('')
const [strategy, setStrategy] = useState<'mobile' | 'desktop'>('mobile')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<any>(null)
const handleAnalyze = async (e: React.FormEvent) => {
e.preventDefault()
if (!url) return
setLoading(true)
setError(null)
setData(null)
let targetUrl = url
if (!/^https?:\/\//i.test(targetUrl)) {
targetUrl = 'https://' + targetUrl
}
try {
const res = await fetch(`/api/insight?url=${encodeURIComponent(targetUrl)}&strategy=${strategy}`)
const result = await res.json()
if (!res.ok) throw new Error(result.error || 'Analiz başarısız')
setData(result)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
const getScoreColor = (score: number) => {
if (score >= 0.9) return 'text-green-500 border-green-500 bg-green-500/10'
if (score >= 0.5) return 'text-orange-500 border-orange-500 bg-orange-500/10'
return 'text-red-500 border-red-500 bg-red-500/10'
}
const getScoreTextColor = (score: number) => {
if (score >= 0.9) return 'text-green-500'
if (score >= 0.5) return 'text-orange-500'
return 'text-red-500'
}
const getScoreBgColor = (score: number) => {
if (score >= 0.9) return 'bg-green-500'
if (score >= 0.5) return 'bg-orange-500'
return 'bg-red-500'
}
const formatMetric = (value: number, unit: string = 's') => {
if (unit === 's') return `${(value / 1000).toFixed(1)} s`
if (unit === 'ms') return `${Math.round(value)} ms`
return `${value.toFixed(2)}`
}
const opportunities: AuditItem[] = data?.lighthouseResult?.opportunities ?? []
const diagnostics: AuditItem[] = data?.lighthouseResult?.diagnostics ?? []
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text flex items-center gap-2">
<Gauge className="w-6 h-6 text-accent" />
Page Insights
</h1>
<p className="text-muted mt-1">Lighthouse motoru ile web sayfalarınızın performansını, erişilebilirliğini ve SEO'sunu ölçün.</p>
</div>
<div className="bg-surface border border-border rounded-xl p-4 sm:p-6 mb-8">
<form onSubmit={handleAnalyze} className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-muted" />
</div>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://ornek.com"
className="block w-full pl-10 pr-3 py-3 border border-border bg-background rounded-lg text-text placeholder-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
required
/>
</div>
<div className="flex bg-background border border-border rounded-lg p-1">
<button
type="button"
onClick={() => setStrategy('mobile')}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
strategy === 'mobile' ? "bg-surface text-text shadow-sm" : "text-muted hover:text-text"
)}
>
<Smartphone className="w-4 h-4" />
Mobil
</button>
<button
type="button"
onClick={() => setStrategy('desktop')}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
strategy === 'desktop' ? "bg-surface text-text shadow-sm" : "text-muted hover:text-text"
)}
>
<Monitor className="w-4 h-4" />
Masaüstü
</button>
</div>
<button
type="submit"
disabled={loading || !url}
className="bg-accent hover:bg-accent/90 text-white px-8 py-3 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 min-w-[140px]"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Analiz...
</>
) : (
'Analiz Et'
)}
</button>
</form>
{error && (
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex items-start gap-3 text-destructive">
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
<p className="text-sm">{error}</p>
</div>
)}
</div>
{data && data.lighthouseResult && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col items-center mb-8">
<p className="text-sm text-muted mb-2">Test edilen URL</p>
<a href={data.id} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline break-all text-center">
{data.id}
</a>
</div>
{/* Kategori skorları */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{[
{ id: 'performance', label: 'Performans' },
{ id: 'accessibility', label: 'Erişilebilirlik' },
{ id: 'best-practices', label: 'En İyi Pratikler' },
{ id: 'seo', label: 'SEO' },
].map((cat) => {
const score = data.lighthouseResult.categories[cat.id]?.score ?? 0
return (
<div key={cat.id} className="bg-surface border border-border rounded-xl p-6 flex flex-col items-center justify-center text-center">
<div className={cn(
"w-24 h-24 rounded-full border-4 flex items-center justify-center mb-4 transition-all duration-1000",
getScoreColor(score)
)}>
<span className="text-3xl font-bold">{Math.round(score * 100)}</span>
</div>
<h3 className="font-medium text-text">{cat.label}</h3>
</div>
)
})}
</div>
{/* Core Web Vitals */}
<div className="bg-surface border border-border rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2">
<Activity className="w-5 h-5 text-accent" />
<h2 className="font-semibold text-text">Core Web Vitals & Metrikler</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{ id: 'first-contentful-paint', label: 'First Contentful Paint', desc: 'İlk metin veya görselin görünme süresi', unit: 's' },
{ id: 'largest-contentful-paint', label: 'Largest Contentful Paint', desc: 'En büyük içeriğin görünme süresi', unit: 's' },
{ id: 'total-blocking-time', label: 'Total Blocking Time', desc: 'Etkileşimi engelleyen toplam süre', unit: 'ms' },
{ id: 'cumulative-layout-shift', label: 'Cumulative Layout Shift', desc: 'Beklenmeyen düzen kayması miktarı', unit: 'unitless' },
{ id: 'speed-index', label: 'Speed Index', desc: 'İçeriklerin görsel olarak dolma hızı', unit: 's' },
{ id: 'interactive', label: 'Time to Interactive', desc: 'Sayfanın tamamen etkileşimli olma süresi', unit: 's' },
].map((metric) => {
const audit = data.lighthouseResult.audits[metric.id]
if (!audit) return null
return (
<div key={metric.id} className="p-4 rounded-lg bg-background border border-border">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-medium text-text">{metric.label}</h4>
<p className="text-xs text-muted mt-1">{metric.desc}</p>
</div>
<div className={cn("text-lg font-bold shrink-0 ml-2", getScoreTextColor(audit.score))}>
{formatMetric(audit.numericValue, metric.unit)}
</div>
</div>
<div className="w-full bg-surface-2 rounded-full h-1.5 mt-3">
<div
className={cn("h-1.5 rounded-full", getScoreBgColor(audit.score))}
style={{ width: `${Math.max(5, audit.score * 100)}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
{/* Fırsatlar */}
{opportunities.length > 0 && (
<div className="bg-surface border border-border rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2">
<Zap className="w-5 h-5 text-orange-500" />
<h2 className="font-semibold text-text">Fırsatlar</h2>
<span className="text-xs text-muted ml-auto">Sayfa yüklenme süresini kısaltabilecek öneriler</span>
</div>
<div className="divide-y divide-border">
{opportunities.map((audit) => (
<div key={audit.id} className="px-6 py-4 flex items-start gap-4">
<div className="mt-0.5 shrink-0">
{audit.score !== null && audit.score < 0.5
? <XCircle className="w-4 h-4 text-red-500" />
: <AlertTriangle className="w-4 h-4 text-orange-500" />
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text">{audit.title}</p>
{audit.description && (
<p className="text-xs text-muted mt-0.5 line-clamp-1">
{stripMarkdown(audit.description)}
</p>
)}
</div>
{audit.displayValue && (
<span className={cn(
"text-sm font-mono shrink-0",
audit.score !== null && audit.score < 0.5 ? 'text-red-500' : 'text-orange-500'
)}>
{audit.displayValue}
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Teşhisler */}
{diagnostics.length > 0 && (
<div className="bg-surface border border-border rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-background/50 flex items-center gap-2">
<Info className="w-5 h-5 text-accent" />
<h2 className="font-semibold text-text">Teşhisler</h2>
<span className="text-xs text-muted ml-auto">Performansı etkileyen ek bilgiler</span>
</div>
<div className="divide-y divide-border">
{diagnostics.map((audit) => {
const passed = audit.score === null || audit.score >= 0.9
return (
<div key={audit.id} className="px-6 py-4 flex items-start gap-4">
<div className="mt-0.5 shrink-0">
{audit.score === null
? <Info className="w-4 h-4 text-muted" />
: passed
? <CheckCircle2 className="w-4 h-4 text-green-500" />
: audit.score < 0.5
? <XCircle className="w-4 h-4 text-red-500" />
: <AlertTriangle className="w-4 h-4 text-orange-500" />
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text">{audit.title}</p>
{audit.description && (
<p className="text-xs text-muted mt-0.5 line-clamp-1">
{stripMarkdown(audit.description)}
</p>
)}
</div>
{audit.displayValue && (
<span className={cn(
"text-sm font-mono shrink-0",
audit.score === null ? 'text-muted' : passed ? 'text-green-500' : audit.score < 0.5 ? 'text-red-500' : 'text-orange-500'
)}>
{audit.displayValue}
</span>
)}
</div>
)
})}
</div>
</div>
)}
{/* Legend */}
<div className="flex items-center justify-center gap-6 text-sm text-muted pt-4">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-red-500" />
<span>0-49 Zayıf</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-orange-500" />
<span>50-89 Ortalama</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-green-500" />
<span>90-100 İyi</span>
</div>
</div>
</div>
)}
</div>
)
}
+2 -1
View File
@@ -1,7 +1,7 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { LayoutDashboard, Activity, Database, LineChart, Server, LogOut, Box, Cloud } from 'lucide-react'
import { LayoutDashboard, Activity, Database, LineChart, Server, LogOut, Box, Cloud, Gauge } from 'lucide-react'
import { cn } from '@/lib/utils'
const nav = [
@@ -12,6 +12,7 @@ const nav = [
{ href: '/dashboard/services', label: 'Servisler', icon: Server },
{ href: '/dashboard/docker', label: 'Docker', icon: Box },
{ href: '/dashboard/connections', label: 'Bağlantılar', icon: Cloud },
{ href: '/dashboard/insight', label: 'Insight', icon: Gauge },
]
export default function Sidebar() {