mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
5 Commits
@@ -37,6 +37,7 @@ import {
|
||||
ApiKeysCardEditor,
|
||||
PayloadFilterRulesEditor,
|
||||
PayloadRulesEditor,
|
||||
StringListEditor,
|
||||
} from './VisualConfigEditorBlocks';
|
||||
import styles from './VisualConfigEditor.module.scss';
|
||||
|
||||
@@ -218,6 +219,10 @@ export function VisualConfigEditor({
|
||||
(apiKeysText: string) => onChange({ apiKeysText }),
|
||||
[onChange]
|
||||
);
|
||||
const handlePluginStoreSourcesChange = useCallback(
|
||||
(pluginStoreSources: string[]) => onChange({ pluginStoreSources }),
|
||||
[onChange]
|
||||
);
|
||||
const handlePayloadDefaultRulesChange = useCallback(
|
||||
(payloadDefaultRules: PayloadRule[]) => onChange({ payloadDefaultRules }),
|
||||
[onChange]
|
||||
@@ -667,6 +672,33 @@ export function VisualConfigEditor({
|
||||
/>
|
||||
</SectionGrid>
|
||||
|
||||
<SectionSubsection
|
||||
title={t('config_management.visual.sections.system.plugin_store_sources')}
|
||||
description={t(
|
||||
'config_management.visual.sections.system.plugin_store_sources_desc'
|
||||
)}
|
||||
>
|
||||
<div className={styles.fieldShell}>
|
||||
<label className={styles.fieldLabel}>
|
||||
{t('config_management.visual.sections.system.plugin_store_sources_label')}
|
||||
</label>
|
||||
<StringListEditor
|
||||
value={values.pluginStoreSources}
|
||||
disabled={disabled}
|
||||
placeholder={t(
|
||||
'config_management.visual.sections.system.plugin_store_sources_placeholder'
|
||||
)}
|
||||
inputAriaLabel={t(
|
||||
'config_management.visual.sections.system.plugin_store_sources_label'
|
||||
)}
|
||||
onChange={handlePluginStoreSourcesChange}
|
||||
/>
|
||||
<div className={styles.fieldHint}>
|
||||
{t('config_management.visual.sections.system.plugin_store_sources_hint')}
|
||||
</div>
|
||||
</div>
|
||||
</SectionSubsection>
|
||||
|
||||
<SectionGrid>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.system.logs_max_size')}
|
||||
|
||||
@@ -389,7 +389,7 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
|
||||
);
|
||||
});
|
||||
|
||||
const StringListEditor = memo(function StringListEditor({
|
||||
export const StringListEditor = memo(function StringListEditor({
|
||||
value,
|
||||
disabled,
|
||||
placeholder,
|
||||
|
||||
@@ -20,7 +20,12 @@ import { pluginStoreApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { getErrorMessage, isRecord } from '@/utils/helpers';
|
||||
import type { PluginStoreEntry, PluginStoreResponse } from '@/types';
|
||||
import { buildRepositoryURL, isOfficialPlugin, resolvePluginAssetURL } from './pluginResources';
|
||||
import {
|
||||
buildRepositoryURL,
|
||||
isDefaultPluginStoreSource,
|
||||
isOfficialPlugin,
|
||||
resolvePluginAssetURL,
|
||||
} from './pluginResources';
|
||||
import { PluginInstallGateModal } from './components/PluginInstallGateModal';
|
||||
import styles from './PluginStorePage.module.scss';
|
||||
|
||||
@@ -327,9 +332,10 @@ export function PluginStorePage() {
|
||||
: entry.version
|
||||
? `v${entry.version}`
|
||||
: '';
|
||||
const sourceText = entry.sourceName
|
||||
? t('plugin_store.source_name', { source: entry.sourceName })
|
||||
: '';
|
||||
const sourceName = isDefaultPluginStoreSource(entry)
|
||||
? t('plugin_store.cli_proxy_api_source')
|
||||
: entry.sourceName;
|
||||
const sourceText = sourceName ? t('plugin_store.source_name', { source: sourceName }) : '';
|
||||
const metaItems = [versionText, sourceText, entry.author, entry.license].filter(Boolean);
|
||||
const isInstalling = installingKey === entryKey;
|
||||
const hasPendingInstall = Boolean(installingKey);
|
||||
|
||||
@@ -50,6 +50,43 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.repoLink {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
font-family: $font-mono;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-color);
|
||||
text-decoration: underline;
|
||||
|
||||
svg {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--focus-ring-color, var(--accent-color));
|
||||
outline-offset: 3px;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.source {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { IconAlertTriangle, IconPlug } from '@/components/ui/icons';
|
||||
import { IconAlertTriangle, IconExternalLink, IconPlug } from '@/components/ui/icons';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import type { PluginStoreEntry } from '@/types';
|
||||
import {
|
||||
buildRepositoryURL,
|
||||
getPluginConfirmToken,
|
||||
getPluginRepositorySlug,
|
||||
isDefaultPluginStoreSource,
|
||||
resolvePluginAssetURL,
|
||||
} from '../pluginResources';
|
||||
import styles from './PluginInstallGateModal.module.scss';
|
||||
@@ -59,9 +61,14 @@ export function PluginInstallGateModal({
|
||||
|
||||
const title = entry.name || entry.id;
|
||||
const repoSlug = getPluginRepositorySlug(entry.repository);
|
||||
const repositoryURL = buildRepositoryURL(entry.repository);
|
||||
const repoLabel = repoSlug || entry.id;
|
||||
const token = getPluginConfirmToken(entry);
|
||||
const logo = resolvePluginAssetURL(entry.logo, apiBase);
|
||||
const sourceText = entry.sourceName || entry.sourceUrl;
|
||||
const rawSourceText = entry.sourceName || entry.sourceUrl;
|
||||
const sourceText = isDefaultPluginStoreSource(entry)
|
||||
? t('plugin_store.cli_proxy_api_source')
|
||||
: rawSourceText;
|
||||
const tokenMatches = typed.trim() === token;
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -83,7 +90,21 @@ export function PluginInstallGateModal({
|
||||
<GateLogo src={logo} />
|
||||
</div>
|
||||
<h3 className={styles.name}>{title}</h3>
|
||||
<p className={styles.slug}>{repoSlug || entry.id}</p>
|
||||
{repositoryURL ? (
|
||||
<a
|
||||
className={styles.repoLink}
|
||||
href={repositoryURL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('plugin_store.open_repository')}
|
||||
aria-label={t('plugin_store.open_repository')}
|
||||
>
|
||||
<span>{repoLabel}</span>
|
||||
<IconExternalLink size={12} />
|
||||
</a>
|
||||
) : (
|
||||
<p className={styles.slug}>{repoLabel}</p>
|
||||
)}
|
||||
{sourceText ? (
|
||||
<p className={styles.source}>{t('plugin_store.source_name', { source: sourceText })}</p>
|
||||
) : null}
|
||||
|
||||
@@ -46,6 +46,8 @@ export const buildRepositoryURL = (repository: string) => {
|
||||
// hosts like "https://github.com.evil.com/router-for-me/..." from being
|
||||
// mistaken for the official org.
|
||||
export const OFFICIAL_PLUGIN_REPO_PREFIX = 'https://github.com/router-for-me/';
|
||||
export const DEFAULT_PLUGIN_STORE_SOURCE_ID = 'official';
|
||||
const DEFAULT_PLUGIN_STORE_SOURCE_NAME = 'official';
|
||||
|
||||
// Normalize an "owner/repo" slug or repository URL to a bare "owner/repo".
|
||||
export const getPluginRepositorySlug = (repository: string): string => {
|
||||
@@ -72,6 +74,12 @@ export const isOfficialRepository = (repository: string): boolean =>
|
||||
export const isOfficialPlugin = (entry: PluginStoreEntry): boolean =>
|
||||
isOfficialRepository(entry.repository);
|
||||
|
||||
export const isDefaultPluginStoreSource = (
|
||||
entry: Pick<PluginStoreEntry, 'sourceId' | 'sourceName'>
|
||||
): boolean =>
|
||||
entry.sourceId.trim().toLowerCase() === DEFAULT_PLUGIN_STORE_SOURCE_ID ||
|
||||
entry.sourceName.trim().toLowerCase() === DEFAULT_PLUGIN_STORE_SOURCE_NAME;
|
||||
|
||||
// The string a user must retype to confirm a risky install: the repo slug when
|
||||
// available (most faithful to the source), otherwise the plugin id.
|
||||
export const getPluginConfirmToken = (entry: PluginStoreEntry): string =>
|
||||
|
||||
@@ -120,6 +120,15 @@ function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void
|
||||
}
|
||||
}
|
||||
|
||||
function setStringListInDoc(doc: YamlDocument, path: YamlPath, values: string[]): void {
|
||||
const nextValues = values.map((value) => value.trim()).filter(Boolean);
|
||||
if (nextValues.length > 0) {
|
||||
doc.setIn(path, nextValues);
|
||||
return;
|
||||
}
|
||||
if (docHas(doc, path)) doc.deleteIn(path);
|
||||
}
|
||||
|
||||
function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
|
||||
const safe = typeof value === 'string' ? value : '';
|
||||
const trimmed = safe.trim();
|
||||
@@ -790,6 +799,13 @@ function getNextDirtyFields(
|
||||
] as Array<keyof VisualConfigValues>
|
||||
).forEach(updateScalarDirty);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'pluginStoreSources')) {
|
||||
updateDirty(
|
||||
'pluginStoreSources',
|
||||
areStringArraysEqual(nextValues.pluginStoreSources, baselineValues.pluginStoreSources)
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
|
||||
updateDirty(
|
||||
'payloadDefaultRules',
|
||||
@@ -957,6 +973,7 @@ export function useVisualConfig() {
|
||||
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||
apiKeysText: resolveApiKeysText(parsed),
|
||||
pluginsEnabled: Boolean(plugins?.enabled),
|
||||
pluginStoreSources: parseStringList(plugins?.['store-sources']),
|
||||
|
||||
debug: Boolean(parsed.debug),
|
||||
commercialMode: Boolean(parsed['commercial-mode']),
|
||||
@@ -1126,10 +1143,28 @@ export function useVisualConfig() {
|
||||
if (
|
||||
docHas(doc, ['plugins']) ||
|
||||
values.pluginsEnabled ||
|
||||
shouldWriteManagedField(doc, ['plugins', 'enabled'], dirtyFields, 'pluginsEnabled')
|
||||
values.pluginStoreSources.length > 0 ||
|
||||
shouldWriteManagedField(doc, ['plugins', 'enabled'], dirtyFields, 'pluginsEnabled') ||
|
||||
shouldWriteManagedField(
|
||||
doc,
|
||||
['plugins', 'store-sources'],
|
||||
dirtyFields,
|
||||
'pluginStoreSources'
|
||||
)
|
||||
) {
|
||||
ensureMapInDoc(doc, ['plugins']);
|
||||
setBooleanInDoc(doc, ['plugins', 'enabled'], values.pluginsEnabled);
|
||||
if (
|
||||
values.pluginStoreSources.length > 0 ||
|
||||
shouldWriteManagedField(
|
||||
doc,
|
||||
['plugins', 'store-sources'],
|
||||
dirtyFields,
|
||||
'pluginStoreSources'
|
||||
)
|
||||
) {
|
||||
setStringListInDoc(doc, ['plugins', 'store-sources'], values.pluginStoreSources);
|
||||
}
|
||||
deleteIfMapEmpty(doc, ['plugins']);
|
||||
}
|
||||
|
||||
|
||||
@@ -920,6 +920,11 @@
|
||||
"logging_to_file_desc": "Save logs to files",
|
||||
"plugins_enabled": "Enable Plugin System",
|
||||
"plugins_enabled_desc": "Enable standard dynamic-library plugin loading; individual plugin instances are still managed on the Plugins page",
|
||||
"plugin_store_sources": "Third-party Plugin Sources",
|
||||
"plugin_store_sources_desc": "Append plugin-store registry sources; the built-in official source is always kept",
|
||||
"plugin_store_sources_label": "Plugin source registry URL (plugins.store-sources)",
|
||||
"plugin_store_sources_placeholder": "https://example.com/cliproxy-plugins/registry.json",
|
||||
"plugin_store_sources_hint": "One registry.json URL per row. Empty rows are filtered on save",
|
||||
"logs_max_size": "Log File Size Limit (MB)",
|
||||
"error_logs_max_files": "Retained Error Log Files",
|
||||
"usage_statistics_enabled": "Enable In-memory Usage Statistics",
|
||||
@@ -1174,6 +1179,7 @@
|
||||
"description_show_more": "Show more",
|
||||
"description_show_less": "Show less",
|
||||
"source_name": "Source: {{source}}",
|
||||
"cli_proxy_api_source": "CLIProxyAPI source",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"load_failed": "Failed to load the plugin store",
|
||||
@@ -1222,8 +1228,8 @@
|
||||
"gate_title": "Install {{name}}",
|
||||
"gate_title_update": "Update {{name}}",
|
||||
"gate_warning": "Unexpected and harmful things can happen if you don't read this.",
|
||||
"gate_effect_runs_code": "This plugin runs untrusted code inside your proxy backend, with access to credentials, requests, and responses.",
|
||||
"gate_effect_no_review": "It is not published or reviewed by the official router-for-me organization and may behave differently in future versions.",
|
||||
"gate_effect_runs_code": "This plugin will run uncertified third-party code inside your proxy backend. The system cannot isolate or audit its behavior, and it may have access to your sensitive credentials, network requests, and response data.",
|
||||
"gate_effect_no_review": "This plugin is not published or reviewed by the official router-for-me organization. The official organization makes no express or implied warranty for this plugin's stability, security, compliance, or any behavior changes in future versions.",
|
||||
"gate_effect_restart": "Installing it changes the local plugin directory and may require a service restart to take effect.",
|
||||
"gate_untrusted_alert": "This plugin is not from the official router-for-me organization. Only continue if you fully trust its author and source.",
|
||||
"gate_repository_label": "Repository",
|
||||
|
||||
@@ -907,6 +907,11 @@
|
||||
"logging_to_file_desc": "Сохранять журналы в файлы",
|
||||
"plugins_enabled": "Включить систему плагинов",
|
||||
"plugins_enabled_desc": "Включает загрузку стандартных dynamic-library плагинов; отдельные экземпляры управляются на странице плагинов",
|
||||
"plugin_store_sources": "Сторонние источники плагинов",
|
||||
"plugin_store_sources_desc": "Добавляет registry-источники магазина плагинов; встроенный официальный источник всегда сохраняется",
|
||||
"plugin_store_sources_label": "URL registry источника плагинов (plugins.store-sources)",
|
||||
"plugin_store_sources_placeholder": "https://example.com/cliproxy-plugins/registry.json",
|
||||
"plugin_store_sources_hint": "Один URL registry.json в строке. Пустые строки отфильтровываются при сохранении",
|
||||
"logs_max_size": "Максимальный размер файла журнала (МБ)",
|
||||
"error_logs_max_files": "Файлов журнала ошибок",
|
||||
"usage_statistics_enabled": "Включить статистику использования в памяти",
|
||||
@@ -1161,6 +1166,7 @@
|
||||
"description_show_more": "Показать больше",
|
||||
"description_show_less": "Свернуть",
|
||||
"source_name": "Источник: {{source}}",
|
||||
"cli_proxy_api_source": "Источник CLIProxyAPI",
|
||||
"refresh": "Обновить",
|
||||
"retry": "Повторить",
|
||||
"load_failed": "Не удалось загрузить магазин плагинов",
|
||||
@@ -1209,8 +1215,8 @@
|
||||
"gate_title": "Установка {{name}}",
|
||||
"gate_title_update": "Обновление {{name}}",
|
||||
"gate_warning": "Если вы не прочитаете это, могут произойти неожиданные и опасные последствия.",
|
||||
"gate_effect_runs_code": "Этот плагин выполняет недоверенный код внутри вашего прокси-бэкенда с доступом к учётным данным, запросам и ответам.",
|
||||
"gate_effect_no_review": "Он не опубликован и не проверен официальной организацией router-for-me и может вести себя иначе в будущих версиях.",
|
||||
"gate_effect_runs_code": "Этот плагин будет выполнять несертифицированный сторонний код внутри вашего прокси-бэкенда. Система не может изолировать или аудировать его поведение, и он может получить доступ к вашим конфиденциальным учётным данным, сетевым запросам и данным ответов.",
|
||||
"gate_effect_no_review": "Этот плагин не опубликован и не проверен официальной организацией router-for-me. Официальная организация не предоставляет никаких явных или подразумеваемых гарантий стабильности, безопасности, соответствия требованиям или любых изменений поведения в последующих версиях этого плагина.",
|
||||
"gate_effect_restart": "Установка изменяет локальный каталог плагинов и может потребовать перезапуска сервиса.",
|
||||
"gate_untrusted_alert": "Этот плагин не от официальной организации router-for-me. Продолжайте, только если полностью доверяете его автору и источнику.",
|
||||
"gate_repository_label": "Репозиторий",
|
||||
|
||||
@@ -920,6 +920,11 @@
|
||||
"logging_to_file_desc": "将日志保存到文件",
|
||||
"plugins_enabled": "启用插件系统",
|
||||
"plugins_enabled_desc": "启用标准动态库插件加载;具体插件实例仍在插件管理页启停",
|
||||
"plugin_store_sources": "第三方插件源",
|
||||
"plugin_store_sources_desc": "追加插件商店 registry 源;内置官方源始终保留",
|
||||
"plugin_store_sources_label": "插件源 registry URL (plugins.store-sources)",
|
||||
"plugin_store_sources_placeholder": "https://example.com/cliproxy-plugins/registry.json",
|
||||
"plugin_store_sources_hint": "每行一个 registry.json URL,保存时会过滤空行",
|
||||
"logs_max_size": "日志文件大小限制 (MB)",
|
||||
"error_logs_max_files": "错误日志保留文件数",
|
||||
"usage_statistics_enabled": "启用内存用量统计",
|
||||
@@ -1174,6 +1179,7 @@
|
||||
"description_show_more": "展开描述",
|
||||
"description_show_less": "收起描述",
|
||||
"source_name": "来源:{{source}}",
|
||||
"cli_proxy_api_source": "CLIProxyAPI源",
|
||||
"refresh": "刷新",
|
||||
"retry": "重试",
|
||||
"load_failed": "插件商店加载失败",
|
||||
@@ -1222,8 +1228,8 @@
|
||||
"gate_title": "安装 {{name}}",
|
||||
"gate_title_update": "更新 {{name}}",
|
||||
"gate_warning": "如果你不阅读以下内容,可能会发生意料之外的危险后果。",
|
||||
"gate_effect_runs_code": "该插件会在你的代理后端内运行不受信任的代码,并可访问凭据、请求与响应。",
|
||||
"gate_effect_no_review": "它并非由官方组织 router-for-me 发布或审核,且在后续版本中行为可能发生变化。",
|
||||
"gate_effect_runs_code": "该插件将在您的代理后端内运行第三方未认证代码。系统无法对其行为进行隔离或审计,其具备访问您的敏感凭据、网络请求与响应数据的潜在权限。",
|
||||
"gate_effect_no_review": "本插件并非由官方组织 router-for-me 发布或审核。官方不对该插件的稳定性、安全性、合规性以及后续版本的任意行为变更承担任何明示或暗示的担保责任。",
|
||||
"gate_effect_restart": "安装会修改本地插件目录,并可能需要重启服务才能生效。",
|
||||
"gate_untrusted_alert": "该插件并非来自官方组织 router-for-me。请仅在你完全信任其作者与来源时继续。",
|
||||
"gate_repository_label": "仓库",
|
||||
|
||||
@@ -946,6 +946,11 @@
|
||||
"logging_to_file_desc": "將記錄儲存到檔案",
|
||||
"plugins_enabled": "啟用插件系統",
|
||||
"plugins_enabled_desc": "啟用標準動態庫插件載入;具體插件實例仍在插件管理頁啟停",
|
||||
"plugin_store_sources": "第三方插件源",
|
||||
"plugin_store_sources_desc": "追加插件商店 registry 源;內建官方源始終保留",
|
||||
"plugin_store_sources_label": "插件源 registry URL(plugins.store-sources)",
|
||||
"plugin_store_sources_placeholder": "https://example.com/cliproxy-plugins/registry.json",
|
||||
"plugin_store_sources_hint": "每列一個 registry.json URL,儲存時會過濾空列",
|
||||
"logs_max_size": "記錄檔大小限制(MB)",
|
||||
"error_logs_max_files": "錯誤記錄保留檔案數",
|
||||
"usage_statistics_enabled": "啟用記憶體用量統計",
|
||||
@@ -1200,6 +1205,7 @@
|
||||
"description_show_more": "展開描述",
|
||||
"description_show_less": "收起描述",
|
||||
"source_name": "來源:{{source}}",
|
||||
"cli_proxy_api_source": "CLIProxyAPI來源",
|
||||
"refresh": "重新整理",
|
||||
"retry": "重試",
|
||||
"load_failed": "插件商店載入失敗",
|
||||
@@ -1248,8 +1254,8 @@
|
||||
"gate_title": "安裝 {{name}}",
|
||||
"gate_title_update": "更新 {{name}}",
|
||||
"gate_warning": "如果你不閱讀以下內容,可能會發生意料之外的危險後果。",
|
||||
"gate_effect_runs_code": "該插件會在你的代理後端內執行不受信任的程式碼,並可存取憑證、請求與回應。",
|
||||
"gate_effect_no_review": "它並非由官方組織 router-for-me 發布或審核,且在後續版本中行為可能發生變化。",
|
||||
"gate_effect_runs_code": "該插件將在您的代理後端內執行第三方未認證程式碼。系統無法對其行為進行隔離或稽核,其具備存取您的敏感憑證、網路請求與回應資料的潛在權限。",
|
||||
"gate_effect_no_review": "本插件並非由官方組織 router-for-me 發布或審核。官方不對該插件的穩定性、安全性、合規性以及後續版本的任意行為變更承擔任何明示或暗示的擔保責任。",
|
||||
"gate_effect_restart": "安裝會修改本機插件目錄,並可能需要重新啟動服務才能生效。",
|
||||
"gate_untrusted_alert": "該插件並非來自官方組織 router-for-me。請僅在你完全信任其作者與來源時繼續。",
|
||||
"gate_repository_label": "儲存庫",
|
||||
|
||||
+56
-20
@@ -52,9 +52,27 @@ const MAX_BUFFER_LINES = 10000;
|
||||
const LONG_PRESS_MS = 650;
|
||||
const LONG_PRESS_MOVE_THRESHOLD = 10;
|
||||
|
||||
const getIncrementalAfter = (cursor: LogsQuery['after']): LogsQuery['after'] => {
|
||||
if (typeof cursor !== 'number') return cursor;
|
||||
return cursor > 1 ? cursor - 1 : undefined;
|
||||
type LogPosition = Pick<LogsQuery, 'after' | 'cursor'>;
|
||||
|
||||
const getIncrementalAfter = (after: LogsQuery['after']): LogsQuery['after'] => {
|
||||
if (typeof after !== 'number') return after;
|
||||
return after > 1 ? after - 1 : undefined;
|
||||
};
|
||||
|
||||
const buildLogsQuery = (incremental: boolean, position: LogPosition): LogsQuery => {
|
||||
const params: LogsQuery = { limit: MAX_BUFFER_LINES };
|
||||
if (!incremental) return params;
|
||||
|
||||
if (position.cursor) {
|
||||
params.cursor = position.cursor;
|
||||
}
|
||||
|
||||
const after = getIncrementalAfter(position.after);
|
||||
if (after !== undefined) {
|
||||
params.after = after;
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const findLineOverlap = (currentLines: string[], incomingLines: string[]): number => {
|
||||
@@ -174,8 +192,29 @@ export function LogsPage() {
|
||||
const logRequestInFlightRef = useRef(false);
|
||||
const pendingFullReloadRef = useRef(false);
|
||||
|
||||
// 保存最新游标用于增量获取
|
||||
const latestCursorRef = useRef<LogsQuery['after']>(undefined);
|
||||
// 保存最新游标用于增量获取;新 CPA 后端优先使用 cursor,旧接口和 Home 继续使用 after。
|
||||
const logPositionRef = useRef<LogPosition>({});
|
||||
|
||||
const resetLogPosition = () => {
|
||||
logPositionRef.current = {};
|
||||
};
|
||||
|
||||
const updateLogPosition = (
|
||||
data: Awaited<ReturnType<typeof logsApi.fetchLogs>>,
|
||||
incremental: boolean
|
||||
) => {
|
||||
const currentPosition = logPositionRef.current;
|
||||
const nextPosition: LogPosition = {};
|
||||
if (data.nextCursor) {
|
||||
nextPosition.cursor = data.nextCursor;
|
||||
}
|
||||
if (data.latestAfter !== undefined) {
|
||||
nextPosition.after = data.latestAfter;
|
||||
} else if (incremental && currentPosition.after !== undefined) {
|
||||
nextPosition.after = currentPosition.after;
|
||||
}
|
||||
logPositionRef.current = nextPosition;
|
||||
};
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const refreshDisabled = disableControls || loading || cpaNeedsFileLogging;
|
||||
@@ -190,7 +229,7 @@ export function LogsPage() {
|
||||
|
||||
if (cpaNeedsFileLogging) {
|
||||
if (!incremental) {
|
||||
latestCursorRef.current = undefined;
|
||||
resetLogPosition();
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
setFileLoggingRequired(false);
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
@@ -222,19 +261,12 @@ export function LogsPage() {
|
||||
scrollerInstance?.requestScrollToBottom();
|
||||
}
|
||||
|
||||
const params: LogsQuery =
|
||||
incremental && latestCursorRef.current
|
||||
? { after: getIncrementalAfter(latestCursorRef.current), limit: MAX_BUFFER_LINES }
|
||||
: { limit: MAX_BUFFER_LINES };
|
||||
const params = buildLogsQuery(incremental, logPositionRef.current);
|
||||
const data = await logsApi.fetchLogs(params);
|
||||
setFileLoggingRequired(false);
|
||||
|
||||
// 更新游标
|
||||
if (data.latestCursor) {
|
||||
latestCursorRef.current = data.latestCursor;
|
||||
} else if (!incremental) {
|
||||
latestCursorRef.current = undefined;
|
||||
}
|
||||
updateLogPosition(data, incremental);
|
||||
|
||||
if (data.requestLogHomeIpById) {
|
||||
requestLogHomeIpByIdRef.current = incremental
|
||||
? { ...requestLogHomeIpByIdRef.current, ...data.requestLogHomeIpById }
|
||||
@@ -245,7 +277,11 @@ export function LogsPage() {
|
||||
|
||||
const newLines = Array.isArray(data.lines) ? data.lines : [];
|
||||
|
||||
if (incremental && newLines.length > 0) {
|
||||
if (incremental && data.cursorReset) {
|
||||
const buffer = newLines.slice(-MAX_BUFFER_LINES);
|
||||
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
|
||||
setLogState({ buffer, visibleFrom });
|
||||
} else if (incremental && newLines.length > 0) {
|
||||
// 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀)
|
||||
setLogState((prev) => {
|
||||
const prevRenderedCount = prev.buffer.length - prev.visibleFrom;
|
||||
@@ -271,7 +307,7 @@ export function LogsPage() {
|
||||
console.error('Failed to load logs:', err);
|
||||
if (isLoggingToFileDisabledError(err)) {
|
||||
if (!incremental) {
|
||||
latestCursorRef.current = undefined;
|
||||
resetLogPosition();
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
setFileLoggingRequired(true);
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
@@ -318,7 +354,7 @@ export function LogsPage() {
|
||||
try {
|
||||
await logsApi.clearLogs();
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
latestCursorRef.current = undefined;
|
||||
resetLogPosition();
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
setFileLoggingRequired(false);
|
||||
showNotification(t('logs.clear_success'), 'success');
|
||||
@@ -429,7 +465,7 @@ export function LogsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === 'connected') {
|
||||
latestCursorRef.current = undefined;
|
||||
resetLogPosition();
|
||||
requestLogHomeIpByIdRef.current = {};
|
||||
setFileLoggingRequired(false);
|
||||
loadLogs(false);
|
||||
|
||||
+39
-34
@@ -11,6 +11,7 @@ export type LogBackendKind = 'unknown' | 'file' | 'home-db';
|
||||
|
||||
export interface LogsQuery {
|
||||
after?: LogCursor;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
@@ -42,7 +43,9 @@ export interface HomeLogsResponse {
|
||||
export interface LogsResponse {
|
||||
lines: string[];
|
||||
lineCount: number;
|
||||
latestCursor?: LogCursor;
|
||||
latestAfter?: LogCursor;
|
||||
nextCursor?: string;
|
||||
cursorReset?: boolean;
|
||||
logBackendKind: LogBackendKind;
|
||||
requestLogHomeIpById?: Record<string, string>;
|
||||
total?: number;
|
||||
@@ -67,6 +70,9 @@ const numberValue = (value: unknown): number | undefined => {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const booleanValue = (value: unknown): boolean =>
|
||||
value === true || (typeof value === 'string' && value.trim().toLowerCase() === 'true');
|
||||
|
||||
const positiveNumberValue = (value: unknown): number | undefined => {
|
||||
const parsed = numberValue(value);
|
||||
return parsed !== undefined && parsed > 0 ? parsed : undefined;
|
||||
@@ -104,8 +110,10 @@ const normalizeCPALogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
return {
|
||||
lines,
|
||||
lineCount: Number.isFinite(lineCount) ? lineCount : lines.length,
|
||||
latestCursor: latestTimestamp > 0 ? latestTimestamp : undefined,
|
||||
logBackendKind: 'file'
|
||||
latestAfter: latestTimestamp > 0 ? latestTimestamp : undefined,
|
||||
nextCursor: stringValue(data['next-cursor']) || undefined,
|
||||
cursorReset: booleanValue(data['cursor-reset']),
|
||||
logBackendKind: 'file',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -140,12 +148,12 @@ const normalizeHomeLogs = (data: Record<string, unknown>): LogsResponse => {
|
||||
return {
|
||||
lines,
|
||||
lineCount: Number.isFinite(total) ? total : lines.length,
|
||||
latestCursor,
|
||||
latestAfter: latestCursor,
|
||||
logBackendKind: 'home-db',
|
||||
requestLogHomeIpById,
|
||||
total: Number.isFinite(total) ? total : undefined,
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
offset: Number.isFinite(offset) ? offset : undefined
|
||||
offset: Number.isFinite(offset) ? offset : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -173,39 +181,36 @@ const fetchCompleteHomeLogs = async (
|
||||
return firstPage;
|
||||
}
|
||||
|
||||
let nextOffset = firstOffset + records.length;
|
||||
const targetCount = Math.min(requestedLimit, Math.max(total - firstOffset, 0));
|
||||
|
||||
while (records.length < targetCount && nextOffset < total) {
|
||||
const remaining = targetCount - records.length;
|
||||
const data = await apiClient.get('/logs', {
|
||||
params: {
|
||||
...params,
|
||||
limit: Math.min(pageLimit, remaining),
|
||||
offset: nextOffset,
|
||||
},
|
||||
timeout: LOGS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!isRecord(data) || !Array.isArray(data.logs)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pageRecords = homeRecordsFromPayload(data);
|
||||
if (pageRecords.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
records.push(...pageRecords);
|
||||
nextOffset += pageRecords.length;
|
||||
if (records.length >= targetCount) {
|
||||
return { ...firstPage, logs: records, limit: records.length, offset: firstOffset };
|
||||
}
|
||||
|
||||
return {
|
||||
...firstPage,
|
||||
logs: records,
|
||||
limit: records.length,
|
||||
offset: firstOffset,
|
||||
};
|
||||
const remaining = targetCount - records.length;
|
||||
const baseOffset = firstOffset + records.length;
|
||||
const pageRequests: Array<{ offset: number; limit: number }> = [];
|
||||
let collected = 0;
|
||||
while (collected < remaining && baseOffset + collected < total) {
|
||||
const pageSize = Math.min(pageLimit, remaining - collected);
|
||||
pageRequests.push({ offset: baseOffset + collected, limit: pageSize });
|
||||
collected += pageSize;
|
||||
}
|
||||
|
||||
const pages = await Promise.all(
|
||||
pageRequests.map(async ({ offset, limit }) => {
|
||||
const data = await apiClient.get('/logs', {
|
||||
params: { ...params, limit, offset },
|
||||
timeout: LOGS_TIMEOUT_MS,
|
||||
});
|
||||
if (!isRecord(data) || !Array.isArray(data.logs)) return [];
|
||||
return homeRecordsFromPayload(data);
|
||||
})
|
||||
);
|
||||
|
||||
pages.forEach((pageRecords) => records.push(...pageRecords));
|
||||
|
||||
return { ...firstPage, logs: records, limit: records.length, offset: firstOffset };
|
||||
};
|
||||
|
||||
export const logsApi = {
|
||||
|
||||
@@ -81,6 +81,7 @@ export type VisualConfigValues = {
|
||||
authDir: string;
|
||||
apiKeysText: string;
|
||||
pluginsEnabled: boolean;
|
||||
pluginStoreSources: string[];
|
||||
debug: boolean;
|
||||
commercialMode: boolean;
|
||||
loggingToFile: boolean;
|
||||
@@ -145,6 +146,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
authDir: '',
|
||||
apiKeysText: '',
|
||||
pluginsEnabled: false,
|
||||
pluginStoreSources: [],
|
||||
debug: false,
|
||||
commercialMode: false,
|
||||
loggingToFile: false,
|
||||
|
||||
Reference in New Issue
Block a user