Merge pull request #303 from router-for-me:home

feat(logs): enhance logging functionality and UI updates
This commit is contained in:
Chén Mù
2026-06-02 17:32:40 +08:00
committed by GitHub
Unverified
14 changed files with 501 additions and 62 deletions
+6 -11
View File
@@ -213,7 +213,6 @@ export function MainLayout() {
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
@@ -431,16 +430,12 @@ export function MainLayout() {
metaKey: 'nav_meta.quota_management',
icon: sidebarIcons.quota,
},
...(config?.loggingToFile
? [
{
path: '/logs',
labelKey: 'nav.logs',
metaKey: 'nav_meta.logs',
icon: sidebarIcons.logs,
},
]
: []),
{
path: '/logs',
labelKey: 'nav.logs',
metaKey: 'nav_meta.logs',
icon: sidebarIcons.logs,
},
],
},
{
+11
View File
@@ -730,12 +730,16 @@
},
"logs": {
"title": "Logs Viewer",
"runtime_unknown": "Current runtime: detecting",
"runtime_cpa": "Current runtime: CPA",
"runtime_home": "Current runtime: Home",
"refresh_button": "Refresh Logs",
"clear_button": "Clear Logs",
"download_button": "Download Logs",
"error_log_button": "Select Error Log",
"error_logs_modal_title": "Error Request Logs",
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
"error_logs_home_unavailable": "In Home mode, use Log Content for database logs. Error request log files only apply to CPA file logs.",
"error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.",
"error_logs_empty": "No error request log files found",
"error_logs_load_error": "Failed to load error log list",
@@ -748,6 +752,13 @@
"request_log_download_success": "Request log downloaded successfully",
"empty_title": "No Logs Available",
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"cpa_file_logging_required": "The connected runtime is CPA. Log viewing requires logging to file to be enabled first.",
"cpa_file_logging_required_title": "File logging is required",
"cpa_file_logging_required_desc": "CPA reads this log view from local log files. Enable logging-to-file in the config, then refresh.",
"file_logging_required": "This logs endpoint requires logging to file before file logs can be read.",
"file_logging_required_title": "File logging is required",
"file_logging_required_desc": "This logs endpoint reads local log files. Enable logging-to-file in the config, then refresh.",
"home_clear_unavailable": "Home logs come from database queries and cannot be cleared here.",
"log_content": "Log Content",
"loading": "Loading logs...",
"load_error": "Failed to load logs",
+11
View File
@@ -727,12 +727,16 @@
},
"logs": {
"title": "Просмотр журналов",
"runtime_unknown": "Текущий режим: определяется",
"runtime_cpa": "Текущий режим: CPA",
"runtime_home": "Текущий режим: Home",
"refresh_button": "Обновить журналы",
"clear_button": "Очистить журналы",
"download_button": "Скачать журналы",
"error_log_button": "Выбрать журнал ошибок",
"error_logs_modal_title": "Журналы ошибок запросов",
"error_logs_description": "Выберите файл журнала ошибок запроса для скачивания (создаётся только при отключённом журналировании запросов).",
"error_logs_home_unavailable": "В режиме Home смотрите журналы базы данных на вкладке содержимого. Файлы ошибок запросов относятся только к файловым журналам CPA.",
"error_logs_request_log_enabled": "Журналирование запросов включено, поэтому этот список всегда будет пустым. Отключите журналирование запросов и обновите список, чтобы просмотреть журналы ошибок.",
"error_logs_empty": "Файлы журнала ошибок запросов не найдены",
"error_logs_load_error": "Не удалось загрузить список журналов ошибок",
@@ -745,6 +749,13 @@
"request_log_download_success": "Журнал запросов успешно скачан",
"empty_title": "Журналы недоступны",
"empty_desc": "Когда включена опция \"Включить журналирование в файл\", журналы появятся здесь",
"cpa_file_logging_required": "Подключён режим CPA. Для просмотра журналов сначала включите запись журналов в файл.",
"cpa_file_logging_required_title": "Требуется файловое журналирование",
"cpa_file_logging_required_desc": "CPA читает этот журнал из локальных файлов. Включите logging-to-file в конфигурации и обновите страницу.",
"file_logging_required": "Этот endpoint журналов требует включить запись журналов в файл перед чтением файловых журналов.",
"file_logging_required_title": "Требуется файловое журналирование",
"file_logging_required_desc": "Этот endpoint журналов читает локальные файлы. Включите logging-to-file в конфигурации и обновите страницу.",
"home_clear_unavailable": "Журналы Home загружаются из базы данных и не могут быть очищены здесь.",
"log_content": "Содержимое журнала",
"loading": "Загрузка журналов...",
"load_error": "Не удалось загрузить журналы",
+11
View File
@@ -730,12 +730,16 @@
},
"logs": {
"title": "日志查看",
"runtime_unknown": "当前运行端:识别中",
"runtime_cpa": "当前运行端:CPA",
"runtime_home": "当前运行端:Home",
"refresh_button": "刷新日志",
"clear_button": "清空日志",
"download_button": "下载日志",
"error_log_button": "选择错误日志",
"error_logs_modal_title": "错误请求日志",
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
"error_logs_home_unavailable": "Home 模式下请在日志内容页查看数据库日志,错误请求日志列表仅适用于 CPA 文件日志。",
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
"error_logs_empty": "暂无错误请求日志文件",
"error_logs_load_error": "加载错误日志列表失败",
@@ -748,6 +752,13 @@
"request_log_download_success": "报文下载成功",
"empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"cpa_file_logging_required": "当前连接的是 CPA,日志查看需要先开启“日志记录到文件”。",
"cpa_file_logging_required_title": "需要开启文件日志",
"cpa_file_logging_required_desc": "CPA 的日志接口读取本地日志文件。请在配置中开启 logging-to-file 后再刷新查看。",
"file_logging_required": "当前日志接口需要先开启“日志记录到文件”后才能读取文件日志。",
"file_logging_required_title": "需要开启文件日志",
"file_logging_required_desc": "当前日志接口读取本地日志文件。请在配置中开启 logging-to-file 后再刷新查看。",
"home_clear_unavailable": "Home 日志来自数据库查询,当前不支持从这里清空。",
"log_content": "日志内容",
"loading": "正在加载日志...",
"load_error": "加载日志失败",
+11
View File
@@ -756,12 +756,16 @@
},
"logs": {
"title": "記錄檢視",
"runtime_unknown": "目前執行端:識別中",
"runtime_cpa": "目前執行端:CPA",
"runtime_home": "目前執行端:Home",
"refresh_button": "重新整理記錄",
"clear_button": "清空記錄",
"download_button": "下載記錄",
"error_log_button": "選擇錯誤記錄",
"error_logs_modal_title": "錯誤請求記錄",
"error_logs_description": "請選擇要下載的錯誤請求記錄檔案(僅在關閉請求記錄時產生)。",
"error_logs_home_unavailable": "Home 模式下請在記錄內容頁查看資料庫記錄,錯誤請求記錄清單僅適用於 CPA 檔案記錄。",
"error_logs_request_log_enabled": "目前已開啟請求記錄,按介面約定錯誤請求記錄清單會始終為空。關閉請求記錄後再重新整理即可查看。",
"error_logs_empty": "暫無錯誤請求記錄檔案",
"error_logs_load_error": "載入錯誤記錄清單失敗",
@@ -774,6 +778,13 @@
"request_log_download_success": "封包下載成功",
"empty_title": "暫無記錄",
"empty_desc": "當啟用「記錄到檔案」功能後,記錄將顯示在這裡",
"cpa_file_logging_required": "目前連線的是 CPA,記錄檢視需要先開啟「記錄到檔案」。",
"cpa_file_logging_required_title": "需要開啟檔案記錄",
"cpa_file_logging_required_desc": "CPA 的記錄介面會讀取本機記錄檔。請在設定中開啟 logging-to-file 後再重新整理查看。",
"file_logging_required": "目前記錄介面需要先開啟「記錄到檔案」後才能讀取檔案記錄。",
"file_logging_required_title": "需要開啟檔案記錄",
"file_logging_required_desc": "目前記錄介面會讀取本機記錄檔。請在設定中開啟 logging-to-file 後再重新整理查看。",
"home_clear_unavailable": "Home 記錄來自資料庫查詢,目前不支援從這裡清空。",
"log_content": "記錄內容",
"loading": "正在載入記錄...",
"load_error": "載入記錄失敗",
+32 -1
View File
@@ -13,11 +13,25 @@
}
}
.pageHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
margin: 0 0 $spacing-lg 0;
@include mobile {
align-items: flex-start;
flex-direction: column;
gap: $spacing-xs;
}
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-lg 0;
margin: 0;
}
.tabBar {
@@ -84,6 +98,17 @@
}
}
.runtimeNotice {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid var(--border-color);
border-radius: $radius-full;
color: var(--text-secondary);
background: var(--bg-secondary);
font-size: 12px;
line-height: 1.3;
}
.toolbar {
display: flex;
align-items: center;
@@ -622,6 +647,9 @@
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;
}
.pageHeader {
margin-bottom: $spacing-md;
}
@@ -671,6 +699,9 @@
@media (max-height: 600px) {
.pageTitle {
font-size: 20px;
}
.pageHeader {
margin-bottom: $spacing-sm;
}
+159 -19
View File
@@ -23,7 +23,8 @@ import {
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { logsApi, type LogsQuery } from '@/services/api/logs';
import { versionApi } from '@/services/api/version';
import { copyToClipboard } from '@/utils/clipboard';
import { downloadBlob } from '@/utils/download';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
@@ -61,14 +62,44 @@ const getErrorMessage = (err: unknown): string => {
return typeof message === 'string' ? message : '';
};
const getErrorPayloadText = (err: unknown): string => {
if (typeof err !== 'object' || err === null) return '';
const payloads = [
(err as { data?: unknown }).data,
(err as { details?: unknown }).details
].filter((payload) => payload !== undefined);
return payloads
.map((payload) => {
if (typeof payload === 'string') return payload;
try {
return JSON.stringify(payload);
} catch {
return '';
}
})
.join(' ');
};
const isLoggingToFileDisabledError = (err: unknown): boolean => {
const text = `${getErrorMessage(err)} ${getErrorPayloadText(err)}`.toLowerCase();
return text.includes('logging to file disabled');
};
type TabType = 'logs' | 'errors';
export function LogsPage() {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const serverRuntimeKind = useAuthStore((state) => state.serverRuntimeKind);
const updateServerRuntimeKind = useAuthStore((state) => state.updateServerRuntimeKind);
const config = useConfigStore((state) => state.config);
const requestLogEnabled = config?.requestLog ?? false;
const loggingToFileEnabled = config?.loggingToFile ?? false;
const cpaNeedsFileLogging = serverRuntimeKind === 'cpa' && !loggingToFileEnabled;
const isHomeRuntime = serverRuntimeKind === 'home';
const [fileLoggingRequired, setFileLoggingRequired] = useState(false);
const showFileLoggingRequired = cpaNeedsFileLogging || fileLoggingRequired;
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
@@ -93,6 +124,7 @@ export function LogsPage() {
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const logScrollerRef = useRef<ReturnType<typeof useLogScroller> | null>(null);
const requestLogHomeIpByIdRef = useRef<Record<string, string>>({});
const longPressRef = useRef<{
timer: number | null;
startX: number;
@@ -102,10 +134,13 @@ export function LogsPage() {
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
// 保存最新游标用于增量获取
const latestCursorRef = useRef<LogsQuery['after']>(undefined);
const disableControls = connectionStatus !== 'connected';
const refreshDisabled = disableControls || loading || cpaNeedsFileLogging;
const autoRefreshDisabled = disableControls || showFileLoggingRequired;
const clearDisabled = disableControls || showFileLoggingRequired || isHomeRuntime;
const loadLogs = async (incremental = false) => {
if (connectionStatus !== 'connected') {
@@ -113,6 +148,18 @@ export function LogsPage() {
return;
}
if (cpaNeedsFileLogging) {
if (!incremental) {
latestCursorRef.current = undefined;
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
setLogState({ buffer: [], visibleFrom: 0 });
setError('');
setLoading(false);
}
return;
}
if (logRequestInFlightRef.current) {
if (!incremental) {
pendingFullReloadRef.current = true;
@@ -135,13 +182,25 @@ export function LogsPage() {
scrollerInstance?.requestScrollToBottom();
}
const params =
incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
const params: LogsQuery =
incremental && latestCursorRef.current
? { after: latestCursorRef.current, limit: MAX_BUFFER_LINES }
: { limit: MAX_BUFFER_LINES };
const data = await logsApi.fetchLogs(params);
setFileLoggingRequired(false);
// 更新时间戳
if (data['latest-timestamp']) {
latestTimestampRef.current = data['latest-timestamp'];
// 更新游标
if (data.latestCursor) {
latestCursorRef.current = data.latestCursor;
} else if (!incremental) {
latestCursorRef.current = undefined;
}
if (data.requestLogHomeIpById) {
requestLogHomeIpByIdRef.current = incremental
? { ...requestLogHomeIpByIdRef.current, ...data.requestLogHomeIpById }
: data.requestLogHomeIpById;
} else if (!incremental) {
requestLogHomeIpByIdRef.current = {};
}
const newLines = Array.isArray(data.lines) ? data.lines : [];
@@ -170,6 +229,16 @@ export function LogsPage() {
}
} catch (err: unknown) {
console.error('Failed to load logs:', err);
if (isLoggingToFileDisabledError(err)) {
if (!incremental) {
latestCursorRef.current = undefined;
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(true);
setLogState({ buffer: [], visibleFrom: 0 });
setError('');
}
return;
}
if (!incremental) {
setError(getErrorMessage(err) || t('logs.load_error'));
}
@@ -188,6 +257,18 @@ export function LogsPage() {
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => {
if (isHomeRuntime) {
showNotification(t('logs.home_clear_unavailable'), 'warning');
return;
}
if (cpaNeedsFileLogging) {
showNotification(t('logs.cpa_file_logging_required'), 'warning');
return;
}
if (fileLoggingRequired) {
showNotification(t('logs.file_logging_required'), 'warning');
return;
}
showConfirmation({
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
message: t('logs.clear_confirm'),
@@ -197,7 +278,9 @@ export function LogsPage() {
try {
await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 });
latestTimestampRef.current = 0;
latestCursorRef.current = undefined;
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
showNotification(t('logs.clear_success'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
@@ -221,6 +304,12 @@ export function LogsPage() {
setLoadingErrors(false);
return;
}
if (isHomeRuntime) {
setLoadingErrors(false);
setErrorLogs([]);
setErrorLogsError('');
return;
}
setLoadingErrors(true);
setErrorLogsError('');
@@ -256,11 +345,28 @@ export function LogsPage() {
useEffect(() => {
if (connectionStatus === 'connected') {
latestTimestampRef.current = 0;
latestCursorRef.current = undefined;
requestLogHomeIpByIdRef.current = {};
setFileLoggingRequired(false);
loadLogs(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionStatus]);
}, [connectionStatus, loggingToFileEnabled]);
useEffect(() => {
if (connectionStatus !== 'connected' || serverRuntimeKind !== 'unknown') return;
let cancelled = false;
const detectRuntime = async () => {
const runtimeKind = await versionApi.detectRuntimeKind();
if (!cancelled && (runtimeKind === 'cpa' || runtimeKind === 'home')) {
updateServerRuntimeKind(runtimeKind);
}
};
void detectRuntime();
return () => {
cancelled = true;
};
}, [connectionStatus, serverRuntimeKind, updateServerRuntimeKind]);
useEffect(() => {
if (activeTab !== 'errors') return;
@@ -270,7 +376,7 @@ export function LogsPage() {
}, [activeTab, connectionStatus, requestLogEnabled]);
useEffect(() => {
if (!autoRefresh || connectionStatus !== 'connected') {
if (!autoRefresh || connectionStatus !== 'connected' || showFileLoggingRequired) {
return;
}
const id = window.setInterval(() => {
@@ -278,7 +384,7 @@ export function LogsPage() {
}, 8000);
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRefresh, connectionStatus]);
}, [autoRefresh, connectionStatus, showFileLoggingRequired]);
const visibleLines = useMemo(
() => logState.buffer.slice(logState.visibleFrom),
@@ -423,7 +529,10 @@ export function LogsPage() {
const downloadRequestLog = async (id: string) => {
setRequestLogDownloading(true);
try {
const response = await logsApi.downloadRequestLogById(id);
const response = await logsApi.downloadRequestLogById(
id,
requestLogHomeIpByIdRef.current[id]
);
downloadBlob({
filename: `request-${id}.log`,
blob: new Blob([response.data], { type: 'text/plain' })
@@ -452,7 +561,12 @@ export function LogsPage() {
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.runtimeNotice}>
{t(`logs.runtime_${serverRuntimeKind}`)}
</div>
</div>
<div className={styles.tabBar}>
<button
@@ -474,6 +588,15 @@ export function LogsPage() {
<div className={styles.content}>
{activeTab === 'logs' && (
<Card className={styles.logCard}>
{showFileLoggingRequired && (
<div className="status-badge warning">
{t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required'
: 'logs.file_logging_required'
)}
</div>
)}
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
@@ -647,7 +770,7 @@ export function LogsPage() {
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={disableControls || loading}
disabled={refreshDisabled}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
@@ -658,7 +781,7 @@ export function LogsPage() {
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
disabled={autoRefreshDisabled}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
@@ -682,7 +805,7 @@ export function LogsPage() {
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
disabled={clearDisabled}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
@@ -828,6 +951,19 @@ export function LogsPage() {
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : showFileLoggingRequired ? (
<EmptyState
title={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_title'
: 'logs.file_logging_required_title'
)}
description={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_desc'
: 'logs.file_logging_required_desc'
)}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
@@ -851,7 +987,11 @@ export function LogsPage() {
<div className="stack">
<div className="hint">{t('logs.error_logs_description')}</div>
{requestLogEnabled && (
{isHomeRuntime && (
<div className="status-badge warning">{t('logs.error_logs_home_unavailable')}</div>
)}
{requestLogEnabled && !isHomeRuntime && (
<div>
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
</div>
+29 -9
View File
@@ -10,6 +10,7 @@ const LOG_LATENCY_REGEX =
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
const LOG_NAMED_REQUEST_ID_REGEX = /\brequest[_-]?id=([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\b/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
@@ -87,6 +88,14 @@ const inferLogLevel = (line: string): LogLevel | undefined => {
return undefined;
};
const extractNamedRequestId = (text: string): string | undefined => {
const match = text.match(LOG_NAMED_REQUEST_ID_REGEX);
if (!match) return undefined;
const id = match[1];
if (/^-+$/.test(id)) return undefined;
return id;
};
const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => {
const match = text.match(HTTP_METHOD_REGEX);
if (!match) return {};
@@ -163,16 +172,24 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
}
}
// request id (8-char hex or dashes)
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
// request id
const requestIdIndex = segments.findIndex(
(segment) => LOG_REQUEST_ID_REGEX.test(segment) || Boolean(extractNamedRequestId(segment))
);
if (requestIdIndex >= 0) {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
const namedId = extractNamedRequestId(segments[requestIdIndex]);
if (namedId) {
requestId = namedId;
consumed.add(requestIdIndex);
} else {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
consumed.add(requestIdIndex);
}
}
}
@@ -243,6 +260,10 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
const parsed = extractHttpMethodAndPath(remaining);
method = parsed.method;
path = parsed.path;
if (!requestId) {
requestId = extractNamedRequestId(remaining);
}
}
if (!level) level = inferLogLevel(raw);
@@ -272,4 +293,3 @@ export const parseLogLine = (raw: string): ParsedLogLine => {
message,
};
};
+16 -4
View File
@@ -7,10 +7,15 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { ApiClientConfig, ApiError } from '@/types';
import {
BUILD_DATE_HEADER_KEYS,
CPA_BUILD_DATE_HEADER_KEYS,
CPA_VERSION_HEADER_KEYS,
HOME_BUILD_DATE_HEADER_KEYS,
HOME_VERSION_HEADER_KEYS,
REQUEST_TIMEOUT_MS,
VERSION_HEADER_KEYS
} from '@/utils/constants';
import { computeApiUrl } from '@/utils/connection';
import type { ServerRuntimeKind } from '@/types';
class ApiClient {
private instance: AxiosInstance;
@@ -109,14 +114,21 @@ class ApiClient {
this.instance.interceptors.response.use(
(response) => {
const headers = response.headers as Record<string, string | undefined>;
const version = this.readHeader(headers, VERSION_HEADER_KEYS);
const buildDate = this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
const homeVersion = this.readHeader(headers, HOME_VERSION_HEADER_KEYS);
const homeBuildDate = this.readHeader(headers, HOME_BUILD_DATE_HEADER_KEYS);
const cpaVersion = this.readHeader(headers, CPA_VERSION_HEADER_KEYS);
const cpaBuildDate = this.readHeader(headers, CPA_BUILD_DATE_HEADER_KEYS);
const version = homeVersion || cpaVersion || this.readHeader(headers, VERSION_HEADER_KEYS);
const buildDate =
homeBuildDate || cpaBuildDate || this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
const runtimeKind: ServerRuntimeKind | null =
homeVersion || homeBuildDate ? 'home' : cpaVersion || cpaBuildDate ? 'cpa' : null;
// 触发版本更新事件(后续通过 store 处理)
if (version || buildDate) {
if (version || buildDate || runtimeKind) {
window.dispatchEvent(
new CustomEvent('server-version-update', {
detail: { version: version || null, buildDate: buildDate || null }
detail: { version: version || null, buildDate: buildDate || null, runtimeKind }
})
);
}
+131 -6
View File
@@ -5,14 +5,48 @@
import { apiClient } from './client';
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
export type LogCursor = number | string;
export type LogBackendKind = 'unknown' | 'file' | 'home-db';
export interface LogsQuery {
after?: number;
after?: LogCursor;
limit?: number;
offset?: number;
}
export interface CPALogsResponse {
lines: string[];
'line-count': number;
'latest-timestamp': number;
}
export interface HomeLogRecord {
id?: number;
timestamp?: string | number;
client_ip?: string;
request_id?: string;
home_ip?: string;
level?: string;
line?: string;
created_at?: string | number;
}
export interface HomeLogsResponse {
logs?: HomeLogRecord[];
total?: number;
limit?: number;
offset?: number;
}
export interface LogsResponse {
lines: string[];
'line-count': number;
'latest-timestamp': number;
lineCount: number;
latestCursor?: LogCursor;
logBackendKind: LogBackendKind;
requestLogHomeIpById?: Record<string, string>;
total?: number;
limit?: number;
offset?: number;
}
export interface ErrorLogFile {
@@ -25,9 +59,99 @@ export interface ErrorLogsResponse {
files?: ErrorLogFile[];
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
const stringValue = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
const unixSecondsFromValue = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
const text = stringValue(value);
if (!text) return 0;
const asNumber = Number(text);
if (Number.isFinite(asNumber)) return asNumber;
const asDate = Date.parse(text);
return Number.isFinite(asDate) ? Math.floor(asDate / 1000) : 0;
};
const homeCursorFromRecord = (record: HomeLogRecord): string => {
const timestamp = stringValue(record.timestamp);
if (timestamp) return timestamp;
const createdAt = stringValue(record.created_at);
return createdAt;
};
const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
const lines = Array.isArray(data.lines)
? data.lines.filter((line): line is string => typeof line === 'string')
: [];
const latestTimestamp = unixSecondsFromValue(data['latest-timestamp']);
const lineCount = Number(data['line-count']);
return {
lines,
lineCount: Number.isFinite(lineCount) ? lineCount : lines.length,
latestCursor: latestTimestamp > 0 ? latestTimestamp : undefined,
logBackendKind: 'file'
};
};
const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
const rawLogs = Array.isArray(data.logs)
? data.logs.filter((entry): entry is HomeLogRecord => isRecord(entry))
: [];
const orderedLogs = [...rawLogs].reverse();
const lines = orderedLogs
.map((record) => record.line)
.filter((line): line is string => typeof line === 'string' && line.length > 0);
const requestLogHomeIpById = orderedLogs.reduce<Record<string, string>>((acc, record) => {
const requestId = stringValue(record.request_id);
const homeIp = stringValue(record.home_ip);
if (requestId && homeIp) {
acc[requestId] = homeIp;
}
return acc;
}, {});
const latestCursor = rawLogs.reduce<string | undefined>((latest, record) => {
const cursor = homeCursorFromRecord(record);
if (!cursor) return latest;
if (!latest) return cursor;
const latestTime = Date.parse(latest);
const cursorTime = Date.parse(cursor);
if (!Number.isFinite(latestTime) || !Number.isFinite(cursorTime)) return latest;
return cursorTime > latestTime ? cursor : latest;
}, undefined);
const total = Number(data.total);
const limit = Number(data.limit);
const offset = Number(data.offset);
return {
lines,
lineCount: Number.isFinite(total) ? total : lines.length,
latestCursor,
logBackendKind: 'home-db',
requestLogHomeIpById,
total: Number.isFinite(total) ? total : undefined,
limit: Number.isFinite(limit) ? limit : undefined,
offset: Number.isFinite(offset) ? offset : undefined
};
};
const normalizeLogsResponse = (data: unknown): LogsResponse => {
if (!isRecord(data)) {
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
}
if (Array.isArray(data.logs)) return normalizeHomeLogs(data);
if (Array.isArray(data.lines)) return normalizeCPALogs(data);
return { lines: [], lineCount: 0, logBackendKind: 'unknown' };
};
export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
async fetchLogs(params: LogsQuery = {}): Promise<LogsResponse> {
const data = await apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS });
return normalizeLogsResponse(data);
},
clearLogs: () => apiClient.delete('/logs'),
@@ -40,8 +164,9 @@ export const logsApi = {
timeout: LOGS_TIMEOUT_MS
}),
downloadRequestLogById: (id: string) =>
downloadRequestLogById: (id: string, homeIp?: string) =>
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
params: homeIp ? { home_ip: homeIp } : undefined,
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
+18 -1
View File
@@ -3,7 +3,24 @@
*/
import { apiClient } from './client';
import type { ServerRuntimeKind } from '@/types';
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
export const versionApi = {
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version'),
async detectRuntimeKind(): Promise<ServerRuntimeKind> {
try {
const data = await apiClient.get('/nodes');
return isRecord(data) && Array.isArray(data.nodes) ? 'home' : 'unknown';
} catch (error: unknown) {
const status = isRecord(error) ? error.status : undefined;
if (status === 404 || status === 405) {
return 'cpa';
}
return 'unknown';
}
}
};
+50 -9
View File
@@ -5,10 +5,11 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
import type { AuthState, LoginCredentials, ConnectionStatus, ServerRuntimeKind } from '@/types';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { obfuscatedStorage } from '@/services/storage/secureStorage';
import { apiClient } from '@/services/api/client';
import { versionApi } from '@/services/api/version';
import { useConfigStore } from './useConfigStore';
import { useModelsStore } from './useModelsStore';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
@@ -22,12 +23,26 @@ interface AuthStoreState extends AuthState {
logout: () => void;
checkAuth: () => Promise<boolean>;
restoreSession: () => Promise<boolean>;
updateServerVersion: (version: string | null, buildDate?: string | null) => void;
updateServerVersion: (
version: string | null,
buildDate?: string | null,
runtimeKind?: ServerRuntimeKind | null
) => void;
updateServerRuntimeKind: (runtimeKind: ServerRuntimeKind) => void;
updateConnectionStatus: (status: ConnectionStatus, error?: string | null) => void;
}
let restoreSessionPromise: Promise<boolean> | null = null;
const detectRuntimeKind = async (): Promise<ServerRuntimeKind> => {
try {
return await versionApi.detectRuntimeKind();
} catch (error) {
console.warn('Runtime kind detection failed:', error);
return 'unknown';
}
};
export const useAuthStore = create<AuthStoreState>()(
persist(
(set, get) => ({
@@ -38,6 +53,7 @@ export const useAuthStore = create<AuthStoreState>()(
rememberPassword: false,
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown',
connectionStatus: 'disconnected',
connectionError: null,
@@ -93,7 +109,12 @@ export const useAuthStore = create<AuthStoreState>()(
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
try {
set({ connectionStatus: 'connecting' });
set({
connectionStatus: 'connecting',
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown'
});
useModelsStore.getState().clearCache();
// 配置 API 客户端
@@ -104,6 +125,7 @@ export const useAuthStore = create<AuthStoreState>()(
// 测试连接 - 获取配置
await useConfigStore.getState().fetchConfig(undefined, true);
const runtimeKind = await detectRuntimeKind();
// 登录成功
set({
@@ -112,7 +134,8 @@ export const useAuthStore = create<AuthStoreState>()(
managementKey,
rememberPassword,
connectionStatus: 'connected',
connectionError: null
connectionError: null,
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
});
if (rememberPassword) {
localStorage.setItem('isLoggedIn', 'true');
@@ -145,6 +168,7 @@ export const useAuthStore = create<AuthStoreState>()(
managementKey: '',
serverVersion: null,
serverBuildDate: null,
serverRuntimeKind: 'unknown',
connectionStatus: 'disconnected',
connectionError: null
});
@@ -165,10 +189,12 @@ export const useAuthStore = create<AuthStoreState>()(
// 验证连接
await useConfigStore.getState().fetchConfig();
const runtimeKind = await detectRuntimeKind();
set({
isAuthenticated: true,
connectionStatus: 'connected'
connectionStatus: 'connected',
...(runtimeKind !== 'unknown' ? { serverRuntimeKind: runtimeKind } : {})
});
return true;
@@ -182,8 +208,16 @@ export const useAuthStore = create<AuthStoreState>()(
},
// 更新服务器版本
updateServerVersion: (version, buildDate) => {
set({ serverVersion: version || null, serverBuildDate: buildDate || null });
updateServerVersion: (version, buildDate, runtimeKind) => {
set((state) => ({
serverVersion: version || null,
serverBuildDate: buildDate || null,
serverRuntimeKind: runtimeKind || state.serverRuntimeKind
}));
},
updateServerRuntimeKind: (runtimeKind) => {
set({ serverRuntimeKind: runtimeKind });
},
// 更新连接状态
@@ -213,7 +247,8 @@ export const useAuthStore = create<AuthStoreState>()(
...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
rememberPassword: state.rememberPassword,
serverVersion: state.serverVersion,
serverBuildDate: state.serverBuildDate
serverBuildDate: state.serverBuildDate,
serverRuntimeKind: state.serverRuntimeKind
})
}
)
@@ -229,7 +264,13 @@ if (typeof window !== 'undefined') {
'server-version-update',
((e: CustomEvent) => {
const detail = e.detail || {};
useAuthStore.getState().updateServerVersion(detail.version || null, detail.buildDate || null);
const runtimeKind =
detail.runtimeKind === 'cpa' || detail.runtimeKind === 'home'
? detail.runtimeKind
: null;
useAuthStore
.getState()
.updateServerVersion(detail.version || null, detail.buildDate || null, runtimeKind);
}) as EventListener
);
}
+2
View File
@@ -18,10 +18,12 @@ export interface AuthState {
rememberPassword: boolean;
serverVersion: string | null;
serverBuildDate: string | null;
serverRuntimeKind: ServerRuntimeKind;
}
// 连接状态
export type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' | 'error';
export type ServerRuntimeKind = 'unknown' | 'cpa' | 'home';
export interface ConnectionInfo {
status: ConnectionStatus;
+14 -2
View File
@@ -16,8 +16,20 @@ export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管
export const DEFAULT_API_PORT = 8317;
export const MANAGEMENT_API_PREFIX = '/v0/management';
export const REQUEST_TIMEOUT_MS = 30 * 1000;
export const VERSION_HEADER_KEYS = ['x-cpa-version', 'x-server-version'];
export const BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date', 'x-server-build-date'];
export const CPA_VERSION_HEADER_KEYS = ['x-cpa-version'];
export const CPA_BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date'];
export const HOME_VERSION_HEADER_KEYS = ['x-cpa-home-version'];
export const HOME_BUILD_DATE_HEADER_KEYS = ['x-cpa-home-build-date'];
export const VERSION_HEADER_KEYS = [
...HOME_VERSION_HEADER_KEYS,
...CPA_VERSION_HEADER_KEYS,
'x-server-version'
];
export const BUILD_DATE_HEADER_KEYS = [
...HOME_BUILD_DATE_HEADER_KEYS,
...CPA_BUILD_DATE_HEADER_KEYS,
'x-server-build-date'
];
// 日志相关
export const LOGS_TIMEOUT_MS = 60 * 1000;