Files
cloud-host/frontend/src/app/dashboard/logs/page.tsx
T
keyhan 35dd771f63 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>
2026-05-15 15:56:33 +03:30

390 lines
14 KiB
TypeScript

'use client';
import { useState, useEffect, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
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: '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: 'Last hour' },
{ value: '6h', label: 'Last 6 hours' },
{ value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' },
];
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);
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 }),
});
const { data: applications = [] } = useQuery<Application[]>({
queryKey: ['applications'],
queryFn: () => api.get('/applications').then((r) => r.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,
});
const { data: stats } = useQuery<LogStatsResult>({
queryKey: ['log-stats', appId, workload, timeRange],
queryFn: () => {
const params = new URLSearchParams();
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,
});
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
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="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>
<div className="card p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Application</label>
<select
value={appId}
onChange={(e) => {
setAppId(e.target.value);
setPage(1);
}}
className="input w-full text-sm"
>
<option value="">All applications</option>
{applications.map((app) => (
<option key={app.id} value={app.id}>
{app.name}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Source</label>
<select
value={workload}
onChange={(e) => {
setWorkload(e.target.value);
setPage(1);
}}
className="input w-full text-sm"
>
{WORKLOADS.map((w) => (
<option key={w.value || 'all'} value={w.value}>
{w.label}
</option>
))}
</select>
</div>
<div>
<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);
setPage(1);
}}
className="input w-full text-sm"
>
{TIME_RANGES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Search</label>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Search message..."
className="input w-full text-sm"
/>
</div>
</div>
<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 text-sm text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded"
/>
Auto-refresh (5s)
</label>
</div>
</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="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="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="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>
)}
{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>
)}
<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-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 max-h-[600px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<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-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>
)}
{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>
</div>
);
}
export default function LogsPage() {
return (
<Suspense fallback={<div className="p-8 text-center text-gray-500">Loading...</div>}>
<LogsPageContent />
</Suspense>
);
}