feat(logging): implement log pruning functionality

- Add LogPruneService to handle the deletion of old logs based on retention settings.
- Create PruneLogsCommand to provide a console command for log pruning.
- Introduce PruneLogsMessage and PruneLogsHandler for message handling related to log pruning.
- Update the AST cache with new classes and their relationships.
This commit is contained in:
hamed
2026-07-01 21:50:51 +03:30
parent a31e8b4314
commit 7814bcc0de
27 changed files with 1667 additions and 802 deletions
+148 -32
View File
@@ -1,12 +1,14 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TrashIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { PaginatedResponse } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { AppLog } from '../types';
import { formatDateTime } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
import Pagination from '../components/ui/Pagination';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
const LEVEL_META: Record<string, { label: string; cls: string }> = {
@@ -27,12 +29,17 @@ const LEVEL_FILTER_OPTIONS = [
{ value: 'critical', label: 'بحرانی' },
];
type Tab = 'logs' | 'settings';
export default function LogsPage() {
const [tab, setTab] = useState<Tab>('logs');
const [page, setPage] = useState(1);
const [level, setLevel] = useState('');
const [search, setSearch] = useState('');
const [viewLog, setViewLog] = useState<AppLog | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const limit = 25;
const queryClient = useQueryClient();
const logsQuery = useQuery({
queryKey: ['admin-logs', page, level, search],
@@ -44,6 +51,15 @@ export default function LogsPage() {
),
});
const clearMutation = useMutation({
mutationFn: () => api.delete<ApiResponse<{ deleted: number }>>('/api/v1/admin/logs'),
onSuccess: () => {
setConfirmClear(false);
setPage(1);
queryClient.invalidateQueries({ queryKey: ['admin-logs'] });
},
});
const columns: Column<AppLog>[] = [
{ key: 'created_at', header: 'زمان', render: (l) => formatDateTime(l.created_at) },
{
@@ -78,42 +94,71 @@ export default function LogsPage() {
<h1 className="section-title">لاگها</h1>
<div className="muted">رویدادهای ثبتشدهی سیستم (اخطار و بالاتر)</div>
</div>
{tab === 'logs' && (
<button
className="btn danger sm"
onClick={() => setConfirmClear(true)}
disabled={(logsQuery.data?.meta?.totalRecords ?? 0) === 0}
>
<TrashIcon style={{ width: 16, height: 16 }} />
حذف همه لاگها
</button>
)}
</div>
<div className="card">
<div className="card-pad">
<div style={{ marginBottom: 12, display: 'flex', gap: 12, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<input
className="input"
style={{ maxWidth: 260 }}
placeholder="جستجو در متن..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
<div style={{ width: 200 }}>
<SearchableSelect
options={LEVEL_FILTER_OPTIONS}
value={level}
onChange={(v) => { setLevel(v ? String(v) : ''); setPage(1); }}
placeholder="همه سطوح"
<div style={{ display: 'flex', gap: 8, marginBottom: 'var(--gap)' }}>
<button
className={`btn ${tab === 'logs' ? 'primary' : 'ghost'} sm`}
onClick={() => setTab('logs')}
>
فهرست لاگها
</button>
<button
className={`btn ${tab === 'settings' ? 'primary' : 'ghost'} sm`}
onClick={() => setTab('settings')}
>
مدت نگهداری
</button>
</div>
{tab === 'logs' && (
<div className="card">
<div className="card-pad">
<div style={{ marginBottom: 12, display: 'flex', gap: 12, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<input
className="input"
style={{ maxWidth: 260 }}
placeholder="جستجو در متن..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
<div style={{ width: 200 }}>
<SearchableSelect
options={LEVEL_FILTER_OPTIONS}
value={level}
onChange={(v) => { setLevel(v ? String(v) : ''); setPage(1); }}
placeholder="همه سطوح"
/>
</div>
</div>
</div>
<DataTable<AppLog>
columns={columns}
data={logsQuery.data?.data ?? []}
loading={logsQuery.isLoading}
emptyMessage="لاگی یافت نشد"
/>
<Pagination
page={page}
total={logsQuery.data?.meta?.totalRecords ?? 0}
limit={limit}
onPageChange={setPage}
/>
<DataTable<AppLog>
columns={columns}
data={logsQuery.data?.data ?? []}
loading={logsQuery.isLoading}
emptyMessage="لاگی یافت نشد"
/>
<Pagination
page={page}
total={logsQuery.data?.meta?.totalRecords ?? 0}
limit={limit}
onPageChange={setPage}
/>
</div>
</div>
</div>
)}
{tab === 'settings' && <RetentionSettings />}
<Modal open={!!viewLog} title="جزئیات لاگ" onClose={() => setViewLog(null)}>
{viewLog && (
@@ -137,6 +182,77 @@ export default function LogsPage() {
</div>
)}
</Modal>
<ConfirmDialog
open={confirmClear}
title="حذف همه لاگ‌ها"
message="همه‌ی لاگ‌های سیستم برای همیشه حذف می‌شوند. این عمل قابل بازگشت نیست. ادامه می‌دهید؟"
confirmLabel="حذف همه"
danger
loading={clearMutation.isPending}
onConfirm={() => clearMutation.mutate()}
onCancel={() => setConfirmClear(false)}
/>
</div>
);
}
function RetentionSettings() {
const queryClient = useQueryClient();
const [days, setDays] = useState<string>('');
const [touched, setTouched] = useState(false);
const settingsQuery = useQuery({
queryKey: ['admin-settings'],
queryFn: () => api.get<ApiResponse<Record<string, string | boolean>>>('/api/v1/admin/settings'),
});
const current = String(settingsQuery.data?.data?.log_retention_days ?? '');
const value = touched ? days : current;
const saveMutation = useMutation({
mutationFn: (retentionDays: string) =>
api.patch<ApiResponse<Record<string, string | boolean>>>('/api/v1/admin/settings', {
log_retention_days: retentionDays,
}),
onSuccess: () => {
setTouched(false);
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
});
return (
<div className="card">
<div className="card-pad">
<div style={{ maxWidth: 420, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label className="muted" style={{ fontSize: 13, display: 'block', marginBottom: 6 }}>
مدت نگهداری لاگها (روز)
</label>
<input
className="input"
type="number"
min={0}
value={value}
placeholder={settingsQuery.isLoading ? 'در حال بارگذاری...' : '90'}
onChange={(e) => { setDays(e.target.value); setTouched(true); }}
style={{ maxWidth: 200 }}
/>
<div className="muted" style={{ fontSize: 12, marginTop: 6, lineHeight: 1.7 }}>
لاگهای قدیمیتر از این مدت بهصورت روزانه بهصورت خودکار حذف میشوند. مقدار ۰ یعنی نگهداری نامحدود.
</div>
</div>
<div>
<button
className="btn primary sm"
disabled={saveMutation.isPending || !touched || value === current}
onClick={() => saveMutation.mutate(String(parseInt(value || '0', 10)))}
>
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</div>
</div>
);
}