Files
3x-ui/frontend/src/pages/index/LogModal.tsx
T
Sanaei 5fe4f241c1 style(logs): widen the row-count selector in the log modals
At 70px the selector truncated its larger values, so the chosen row
count was hard to read in the panel, Xray and AmneziaWG log modals.
2026-09-15 22:06:21 +02:00

203 lines
6.5 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { parseLogLine } from './logParse';
import './LogModal.css';
interface LogModalProps {
open: boolean;
onClose: () => void;
}
const AUTO_UPDATE_INTERVAL = 5000;
export default function LogModal({ open, onClose }: LogModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('20');
const [level, setLevel] = useState('info');
const [syslog, setSyslog] = useState(false);
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
level,
syslog,
});
if (msg?.success) {
setLogs(msg.obj || []);
}
await PromiseUtil.sleep(300);
} finally {
setLoading(false);
}
}, [rows, level, syslog]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open ? `${rows}\u0000${level}\u0000${syslog}` : null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
if (open) void runRefresh();
}, [open, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
const parsedLogs = useMemo(() => logs.map(parseLogLine), [logs]);
function download() {
FileManager.downloadTextFile(logs.join('\n'), 'x-ui.log');
}
const titleNode = (
<>
{t('pages.index.logs')}
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
);
return (
<Modal
open={open}
footer={null}
width={isMobile ? '100vw' : 800}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
className={isMobile ? 'logmodal-mobile' : undefined}
onCancel={onClose}
title={titleNode}
>
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Space.Compact>
<Select
value={rows}
size="small"
style={{ width: 100 }}
onChange={setRows}
options={[
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1000' },
]}
/>
<Select
value={level}
size="small"
style={{ minWidth: 95 }}
onChange={setLevel}
options={[
{ value: 'debug', label: t('pages.index.logLevelDebug') },
{ value: 'info', label: t('pages.index.logLevelInfo') },
{ value: 'notice', label: t('pages.index.logLevelNotice') },
{ value: 'warning', label: t('pages.index.logLevelWarning') },
{ value: 'err', label: t('pages.index.logLevelError') },
]}
/>
</Space.Compact>
</Form.Item>
<Form.Item>
<Checkbox checked={syslog} onChange={(e) => setSyslog(e.target.checked)}>
SysLog
</Checkbox>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</Form.Item>
</Form>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{parsedLogs.length === 0 ? (
<div className="log-empty">No Record...</div>
) : isMobile ? (
parsedLogs.map((log, idx) => (
<div key={idx} className="log-card">
<div className="log-card-head">
{log.stamp && (
<span className="log-time">
{log.time && <span>{log.time}</span>}
{log.time && log.date ? ' ' : ''}
{log.date && <span className="log-date">{log.date}</span>}
</span>
)}
{log.levelText && (
<span className={`log-level-badge ${log.levelClass}`}>{log.levelText}</span>
)}
</div>
{(log.body || log.service) && (
<div className="log-body">
{log.service && <b>{log.service}</b>}
{log.service && log.body ? ' ' : ''}
{log.body && <span className="log-body-text">{log.body}</span>}
</div>
)}
</div>
))
) : (
parsedLogs.map((log, idx) => (
<div key={idx} className="log-line">
{log.stamp && <span className="log-stamp">{log.stamp}</span>}
{log.stamp && log.levelText ? ' ' : ''}
{log.levelText && (
<span className={`log-level ${log.levelClass}`}>{log.levelText}</span>
)}
{(log.body || log.service) && (
<>
{(log.stamp || log.levelText) && <span> - </span>}
{log.service && <b>{log.service}</b>}
{log.service && log.body ? ' ' : ''}
<span>{log.body}</span>
</>
)}
</div>
))
)}
</div>
</Modal>
);
}