Add unified logs platform with Helm-managed central Elasticsearch.
Deploy cloudhost-logging on cluster registration, ship app and optional service logs to ES with owner isolation, and fix Kibana 8.12 auth via kibana_system. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,401 +1,389 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { Loader2, RefreshCw, Search, Filter, Clock, AlertCircle, AlertTriangle, Info, Bug } from 'lucide-react';
|
||||
|
||||
interface Application {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface LogFilter {
|
||||
appId: string;
|
||||
level: string;
|
||||
from: string;
|
||||
to: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
app?: string;
|
||||
pod?: string;
|
||||
}
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import type { Application, LogEntry, LogSearchResult, LogStatsResult } from '@/types';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
const LOG_LEVELS = [
|
||||
{ value: '', label: 'همه سطوح', color: 'bg-gray-100' },
|
||||
{ value: 'error', label: 'Error', color: 'bg-red-100 text-red-800' },
|
||||
{ value: 'warn', label: 'Warning', color: 'bg-yellow-100 text-yellow-800' },
|
||||
{ value: 'info', label: 'Info', color: 'bg-blue-100 text-blue-800' },
|
||||
{ value: 'debug', label: 'Debug', color: 'bg-gray-100 text-gray-800' },
|
||||
{ value: '', label: 'All levels' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
{ value: 'warn', label: 'Warning' },
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'debug', label: 'Debug' },
|
||||
];
|
||||
|
||||
const WORKLOADS = [
|
||||
{ value: '', label: 'All sources' },
|
||||
{ value: 'app', label: 'Application' },
|
||||
{ value: 'redis', label: 'Redis' },
|
||||
{ value: 'rabbitmq', label: 'RabbitMQ' },
|
||||
{ value: 'database', label: 'Database' },
|
||||
];
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ value: '1h', label: 'ساعت گذشته' },
|
||||
{ value: '6h', label: '6 ساعت گذشته' },
|
||||
{ value: '24h', label: '24 ساعت گذشته' },
|
||||
{ value: '7d', label: 'هفته گذشته' },
|
||||
{ value: 'custom', label: 'بازه دلخواه' },
|
||||
{ value: '1h', label: 'Last hour' },
|
||||
{ value: '6h', label: 'Last 6 hours' },
|
||||
{ value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
];
|
||||
|
||||
export default function LogsPage() {
|
||||
const [filters, setFilters] = useState<LogFilter>({
|
||||
appId: '',
|
||||
level: '',
|
||||
from: '',
|
||||
to: '',
|
||||
search: '',
|
||||
});
|
||||
function levelBadgeClass(level: string) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
return 'bg-amber-100 text-amber-800';
|
||||
case 'info':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
}
|
||||
|
||||
function LogsPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialAppId = searchParams.get('appId') || '';
|
||||
|
||||
const [appId, setAppId] = useState(initialAppId);
|
||||
const [workload, setWorkload] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [timeRange, setTimeRange] = useState('24h');
|
||||
const [page, setPage] = useState(1);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
// Fetch user's applications
|
||||
const { data: applications } = useQuery<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('/api/applications');
|
||||
return data;
|
||||
},
|
||||
useEffect(() => {
|
||||
if (initialAppId) setAppId(initialAppId);
|
||||
}, [initialAppId]);
|
||||
|
||||
const { data: loggingStatus } = useQuery({
|
||||
queryKey: ['logs-status'],
|
||||
queryFn: () => api.get('/logs/status').then((r) => r.data as { available: boolean }),
|
||||
});
|
||||
|
||||
// Fetch logs query
|
||||
const { data: logsResult, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['logs', filters, timeRange],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.appId) params.append('appId', filters.appId);
|
||||
if (filters.level) params.append('level', filters.level);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
|
||||
if (timeRange !== 'custom') {
|
||||
const now = new Date();
|
||||
const from = new Date();
|
||||
switch (timeRange) {
|
||||
case '1h': from.setHours(now.getHours() - 1); break;
|
||||
case '6h': from.setHours(now.getHours() - 6); break;
|
||||
case '24h': from.setDate(now.getDate() - 1); break;
|
||||
case '7d': from.setDate(now.getDate() - 7); break;
|
||||
}
|
||||
params.append('from', from.toISOString());
|
||||
} else {
|
||||
if (filters.from) params.append('from', filters.from);
|
||||
if (filters.to) params.append('to', filters.to);
|
||||
}
|
||||
const { data: applications = [] } = useQuery<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data } = await axios.get(`/api/logs?${params.toString()}`);
|
||||
return data;
|
||||
const buildTimeRange = () => {
|
||||
const now = new Date();
|
||||
const from = new Date();
|
||||
switch (timeRange) {
|
||||
case '1h':
|
||||
from.setHours(now.getHours() - 1);
|
||||
break;
|
||||
case '6h':
|
||||
from.setHours(now.getHours() - 6);
|
||||
break;
|
||||
case '7d':
|
||||
from.setDate(now.getDate() - 7);
|
||||
break;
|
||||
default:
|
||||
from.setDate(now.getDate() - 1);
|
||||
}
|
||||
return { from: from.toISOString(), to: now.toISOString() };
|
||||
};
|
||||
|
||||
const { from, to } = buildTimeRange();
|
||||
|
||||
const {
|
||||
data: logsResult,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
error,
|
||||
} = useQuery<LogSearchResult>({
|
||||
queryKey: ['logs', appId, workload, level, search, timeRange, page],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (appId) params.set('appId', appId);
|
||||
if (workload) params.set('workload', workload);
|
||||
if (level) params.set('level', level);
|
||||
if (search) params.set('search', search);
|
||||
params.set('from', from);
|
||||
params.set('to', to);
|
||||
params.set('page', String(page));
|
||||
params.set('limit', '100');
|
||||
return api.get(`/logs?${params.toString()}`).then((r) => r.data);
|
||||
},
|
||||
enabled: loggingStatus?.available !== false,
|
||||
refetchInterval: autoRefresh ? 5000 : false,
|
||||
});
|
||||
|
||||
// Fetch log stats
|
||||
const { data: logStats } = useQuery({
|
||||
queryKey: ['logStats', filters.appId, timeRange],
|
||||
queryFn: async () => {
|
||||
const { data: stats } = useQuery<LogStatsResult>({
|
||||
queryKey: ['log-stats', appId, workload, timeRange],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.appId) params.append('appId', filters.appId);
|
||||
params.append('period', timeRange === 'custom' ? '24h' : timeRange);
|
||||
|
||||
const { data } = await axios.get(`/api/logs/stats?${params.toString()}`);
|
||||
return data;
|
||||
if (appId) params.set('appId', appId);
|
||||
if (workload) params.set('workload', workload);
|
||||
params.set('period', timeRange);
|
||||
return api.get(`/logs/stats?${params.toString()}`).then((r) => r.data);
|
||||
},
|
||||
enabled: loggingStatus?.available !== false,
|
||||
});
|
||||
|
||||
// Fetch recent errors
|
||||
const { data: recentErrors } = useQuery({
|
||||
queryKey: ['recentErrors', filters.appId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.appId) params.append('appId', filters.appId);
|
||||
params.append('limit', '10');
|
||||
|
||||
const { data } = await axios.get(`/api/logs/errors?${params.toString()}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
|
||||
|
||||
const getLevelBadgeClass = (level: string) => {
|
||||
const levelItem = LOG_LEVELS.find(l => l.value === level.toLowerCase());
|
||||
return levelItem?.color || 'bg-gray-100';
|
||||
};
|
||||
if (loggingStatus && !loggingStatus.available) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto card p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Logging not available</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app,
|
||||
and ask an administrator to deploy the logging stack.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8" dir="rtl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">لاگهای اپلیکیشن</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
مشاهده و جستجو در لاگهای اپلیکیشنهای خود
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" /> Logs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Application, Redis, RabbitMQ, and database logs in one place
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* App Filter */}
|
||||
<div className="card p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
اپلیکیشن
|
||||
</label>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Application</label>
|
||||
<select
|
||||
value={filters.appId}
|
||||
onChange={(e) => setFilters({ ...filters, appId: e.target.value })}
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={appId}
|
||||
onChange={(e) => {
|
||||
setAppId(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
<option value="">همه اپلیکیشنها</option>
|
||||
{applications?.map((app) => (
|
||||
<option value="">All applications</option>
|
||||
{applications.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Level Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
سطح لاگ
|
||||
</label>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Source</label>
|
||||
<select
|
||||
value={filters.level}
|
||||
onChange={(e) => setFilters({ ...filters, level: e.target.value })}
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={workload}
|
||||
onChange={(e) => {
|
||||
setWorkload(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{LOG_LEVELS.map((level) => (
|
||||
<option key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
{WORKLOADS.map((w) => (
|
||||
<option key={w.value || 'all'} value={w.value}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Time Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
بازه زمانی
|
||||
</label>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Level</label>
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) => {
|
||||
setLevel(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{LOG_LEVELS.map((l) => (
|
||||
<option key={l.value || 'all'} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Time range</label>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
onChange={(e) => {
|
||||
setTimeRange(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<option key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
{TIME_RANGES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
جستجو در متن
|
||||
</label>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
placeholder="جستجو..."
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search message..."
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom date range */}
|
||||
{timeRange === 'custom' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
از تاریخ
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
تا تاریخ
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
|
||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{isFetching ? 'در حال بارگذاری...' : 'بروزرسانی'}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-4">
|
||||
<button type="button" onClick={() => refetch()} disabled={isFetching} className="btn-primary text-sm">
|
||||
{isFetching ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 inline animate-spin mr-1" /> Loading
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 inline mr-1" /> Refresh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">بروزرسانی خودکار (هر 5 ثانیه)</span>
|
||||
Auto-refresh (5s)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{logStats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-gray-500">کل لاگها</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{logStats.meta?.period || '-'}
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500">Total ({stats.period})</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4 border-red-200">
|
||||
<div className="text-sm text-red-500">خطاها</div>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{recentErrors?.meta?.limit || 0}
|
||||
</div>
|
||||
<div className="card p-4 border-red-100">
|
||||
<p className="text-xs text-red-600">Errors</p>
|
||||
<p className="text-2xl font-bold text-red-700">{stats.errors}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4 border-yellow-200">
|
||||
<div className="text-sm text-yellow-600">هشدارها</div>
|
||||
<div className="text-2xl font-bold text-yellow-600">-</div>
|
||||
<div className="card p-4 border-amber-100">
|
||||
<p className="text-xs text-amber-600">Warnings</p>
|
||||
<p className="text-2xl font-bold text-amber-700">{stats.warnings}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-gray-500">اپلیکیشن فعال</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{filters.appId
|
||||
? applications?.find(a => a.id === filters.appId)?.name || '-'
|
||||
: `${applications?.length || 0} اپلیکیشن`}
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500">Sources</p>
|
||||
<p className="text-sm font-mono text-gray-800 mt-1">
|
||||
{Object.entries(stats.byWorkload || {})
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(' · ') || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Query Info */}
|
||||
{logsResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
||||
<h3 className="font-medium text-blue-900 mb-2">اطلاعات کوئری</h3>
|
||||
<p className="text-sm text-blue-700 mb-2">{logsResult.usage?.description}</p>
|
||||
<div className="bg-white rounded-lg p-3 font-mono text-sm overflow-x-auto" dir="ltr">
|
||||
<pre>{JSON.stringify(logsResult.query, null, 2)}</pre>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
💡 این کوئری را میتوانید در Elasticsearch یا Kibana اجرا کنید.
|
||||
</p>
|
||||
{error && (
|
||||
<div className="card p-4 border-red-200 bg-red-50 text-red-800 text-sm">
|
||||
Failed to load logs. Ensure logging is enabled on your application and Elasticsearch is running.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">لاگها</h2>
|
||||
{isFetching && (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
در حال بارگذاری...
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">Log entries</h2>
|
||||
{logsResult && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{logsResult.total} total · page {page}/{totalPages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
در حال بارگذاری لاگها...
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2" />
|
||||
Loading logs...
|
||||
</div>
|
||||
) : !logsResult?.hits?.length ? (
|
||||
<div className="p-12 text-center text-gray-500 text-sm">
|
||||
No logs found for the selected filters.
|
||||
{!appId && <p className="mt-2">Deploy an app with logging enabled to start collecting logs.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<div className="overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
زمان
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
سطح
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
اپلیکیشن
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
پیام
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Time</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Level</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">App</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Source</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{/* Sample rows - in production, this would come from actual ES query results */}
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-900 font-mono" dir="ltr">
|
||||
{new Date().toISOString().slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${getLevelBadgeClass('info')}`}>
|
||||
INFO
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
نمونه اپلیکیشن
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 font-mono" dir="ltr">
|
||||
برای مشاهده لاگها، کوئری بالا را در Elasticsearch اجرا کنید
|
||||
</td>
|
||||
</tr>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{logsResult.hits.map((entry: LogEntry) => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 align-top">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-600 whitespace-nowrap">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${levelBadgeClass(entry.level)}`}>
|
||||
{entry.level?.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-800">{entry.applicationName || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-600 capitalize">{entry.workload || 'app'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-800 break-all max-w-xl">
|
||||
{entry.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination info */}
|
||||
{logsResult?.meta && (
|
||||
<div className="px-6 py-4 border-t bg-gray-50 text-sm text-gray-500">
|
||||
صفحه {logsResult.meta.page} | {logsResult.meta.limit} آیتم در هر صفحه
|
||||
{logsResult && logsResult.total > logsResult.limit && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="btn-secondary text-sm disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 inline" /> Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="btn-secondary text-sm disabled:opacity-40"
|
||||
>
|
||||
Next <ChevronRight className="w-4 h-4 inline" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Errors */}
|
||||
{recentErrors && (
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden mt-6">
|
||||
<div className="px-6 py-4 border-b bg-red-50">
|
||||
<h2 className="font-semibold text-red-900">آخرین خطاها</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
برای مشاهده خطاهای اخیر، کوئری زیر را در Elasticsearch اجرا کنید:
|
||||
</p>
|
||||
<div className="bg-gray-100 rounded-lg p-3 mt-2 font-mono text-sm overflow-x-auto" dir="ltr">
|
||||
<pre>{JSON.stringify(recentErrors.query, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kibana Link */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-6 mt-6">
|
||||
<h3 className="font-semibold text-purple-900 mb-2">🔍 مشاهده در Kibana</h3>
|
||||
<p className="text-purple-700 mb-4">
|
||||
برای تجربه بهتر در مشاهده و آنالیز لاگها، از Kibana استفاده کنید.
|
||||
</p>
|
||||
<div className="bg-white rounded-lg p-3 font-mono text-sm" dir="ltr">
|
||||
kubectl port-forward svc/kibana 5601:5601 -n logging
|
||||
</div>
|
||||
<p className="text-sm text-purple-600 mt-2">
|
||||
سپس به آدرس http://localhost:5601 بروید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-center text-gray-500">Loading...</div>}>
|
||||
<LogsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user