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:
co-authored by
Claude Sonnet 4.6
parent
a4c58135df
commit
851ce96703
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
.env*.local
|
||||
.dockerignore
|
||||
README.md
|
||||
+8
-2
@@ -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
@@ -2,7 +2,7 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
/* config options here */
|
||||
serverExternalPackages: ['lighthouse', 'chrome-launcher'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+2099
-19
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user