fix: 深度review后续修复——安全强化、i18n补全、代码清理
安全修复: - PBKDF2迭代次数从100k提升至310k(OWASP 2023 SHA-512标准) - 密码最小长度从8位提升至12位 - sanitizeProviderData正则加强:data:协议拒绝而非部分移除, on\w+事件处理器更严格,javascript:加单词边界防止误匹配 - withFileLock错误处理改为重新抛出,不再静默吞错误 - 后端interval上限校验(MAX_INTERVAL_MS)确保配置一致性 功能修复: - 重命名performHealthChecks/performScheduledHealthChecks方法, 明确区分初始化检查和定时检查的职责 - generateUUID回退方案兼容Node.js <14.17.0 - 凭据无expiry字段时强制刷新(安全措施) 代码清理: - 移除未使用的RETRY.DEFAULT_RETRIES常量 - 添加定时健康检查完整英文i18n翻译
This commit is contained in:
parent
740f930f34
commit
1018750388
8 changed files with 136 additions and 74 deletions
|
|
@ -116,8 +116,11 @@ export class ProviderPoolManager {
|
|||
const credData = JSON.parse(fileContent);
|
||||
const expiryTime = credData.expiry_date || credData.expiry || credData.expires_at;
|
||||
const nearExpiryMs = (currentConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000;
|
||||
const isNearExpiry = expiryTime && (expiryTime - Date.now()) < nearExpiryMs;
|
||||
if (isNearExpiry) {
|
||||
if (!expiryTime) {
|
||||
// 凭据文件缺少 expiry 字段,无法判断是否快过期,作为安全措施强制刷新
|
||||
this._log('warn', `Node ${providerStatus.uuid} (${providerType}) has no expiry field. Forcing refresh as safety measure...`);
|
||||
this._enqueueRefresh(providerType, providerStatus);
|
||||
} else if ((expiryTime - Date.now()) < nearExpiryMs) {
|
||||
this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`);
|
||||
this._enqueueRefresh(providerType, providerStatus);
|
||||
}
|
||||
|
|
@ -1692,14 +1695,15 @@ export class ProviderPoolManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Performs health checks on selected providers.
|
||||
* Performs initial (startup) health checks on selected providers.
|
||||
* Respects SCHEDULED_HEALTH_CHECK.providerTypes configuration.
|
||||
*
|
||||
* Called once at server startup.
|
||||
*
|
||||
* 设计决策:如果没有选择任何 provider types,则不进行检查任何 provider。
|
||||
* 这是有意为之的设计 - 如果用户没有明确选择,则不需要自动健康检查。
|
||||
* 区别于原来的逻辑(检查所有 provider),现在的行为更符合用户预期。
|
||||
*/
|
||||
async performHealthChecks() {
|
||||
async performInitialHealthChecks() {
|
||||
const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK;
|
||||
const selectedProviderTypes = scheduledConfig?.providerTypes;
|
||||
|
||||
|
|
@ -1788,7 +1792,7 @@ export class ProviderPoolManager {
|
|||
* This method is designed to be called periodically to proactively check provider health.
|
||||
* It respects provider-level isDisabled flag.
|
||||
*/
|
||||
async performScheduledHealthChecks() {
|
||||
async performHealthChecks() {
|
||||
const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK;
|
||||
const checkStartTime = Date.now();
|
||||
|
||||
|
|
@ -1920,7 +1924,7 @@ export class ProviderPoolManager {
|
|||
* Performs an actual health check for a specific provider.
|
||||
*
|
||||
* 设计决策:不检查 providerConfig.checkHealth 标志。
|
||||
* 健康检查是否执行由上层调用方(performScheduledHealthChecks / performHealthChecks)
|
||||
* 健康检查是否执行由上层调用方(performHealthChecks / performInitialHealthChecks)
|
||||
* 通过 providerTypes 数组来决定,不在每个 provider 级别控制。
|
||||
* 这样简化了逻辑,避免 per-provider 的 checkHealth flag 变得无用。
|
||||
*
|
||||
|
|
@ -1972,7 +1976,7 @@ export class ProviderPoolManager {
|
|||
await serviceAdapter.generateContent(modelName, requestWithSignal);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
// 注意:使用量计数由调用方处理(performScheduledHealthChecks/performHealthChecks)
|
||||
// 注意:使用量计数由调用方处理(performHealthChecks/performInitialHealthChecks)
|
||||
// 这里只返回成功结果,让调用方统一处理状态更新和计数
|
||||
return { success: true, modelName, errorMessage: null };
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function initializeAPIManagement(services) {
|
|||
logger.info(`[Heartbeat] Server is running. Current time: ${new Date().toLocaleString()}`, Object.keys(services));
|
||||
// 循环遍历所有已初始化的服务适配器,并尝试刷新令牌
|
||||
// if (getProviderPoolManager()) {
|
||||
// await getProviderPoolManager().performHealthChecks(); // 定期执行健康检查
|
||||
// await getProviderPoolManager().performInitialHealthChecks(); // 定期执行健康检查
|
||||
// }
|
||||
for (const providerKey in services) {
|
||||
const serviceAdapter = services[providerKey];
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@ async function startServer() {
|
|||
const poolManager = getProviderPoolManager();
|
||||
if (poolManager) {
|
||||
logger.info('[Initialization] Performing initial health checks for provider pools...');
|
||||
poolManager.performHealthChecks();
|
||||
poolManager.performInitialHealthChecks();
|
||||
}
|
||||
|
||||
// 定时健康检查
|
||||
|
|
@ -394,7 +394,7 @@ async function startServer() {
|
|||
}
|
||||
isHealthCheckRunning = true;
|
||||
try {
|
||||
await poolManager.performScheduledHealthChecks();
|
||||
await poolManager.performHealthChecks();
|
||||
} catch (error) {
|
||||
logger.error('[ScheduledHealthCheck] Error:', error);
|
||||
} finally {
|
||||
|
|
@ -426,13 +426,13 @@ async function startServer() {
|
|||
// 启动时运行健康检查
|
||||
if (scheduledConfig.startupRun !== false) {
|
||||
logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...');
|
||||
setImmediate(async () => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await poolManager.performScheduledHealthChecks();
|
||||
} catch (error) {
|
||||
logger.error('[ScheduledHealthCheck] Startup run error:', error);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 设置定时任务
|
||||
|
|
|
|||
|
|
@ -129,11 +129,20 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
|||
// 防止路径遍历:解析后的绝对路径必须在工作目录内
|
||||
const resolved = path.resolve(process.cwd(), p);
|
||||
const cwd = process.cwd();
|
||||
// Windows兼容:统一使用正斜杠进行比较
|
||||
const normalizedResolved = resolved.replace(/\\/g, '/');
|
||||
const normalizedCwd = cwd.replace(/\\/g, '/');
|
||||
if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) {
|
||||
|
||||
// 使用 path.relative 和 path.isAbsolute 进行更严格的校验
|
||||
const relativePath = path.relative(cwd, resolved);
|
||||
const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..';
|
||||
|
||||
// Windows 大小写不敏感兼容:统一转换为小写比较
|
||||
const normalizedResolved = resolved.toLowerCase().replace(/\\/g, '/');
|
||||
const normalizedCwd = cwd.toLowerCase().replace(/\\/g, '/');
|
||||
const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd;
|
||||
|
||||
if (isInsideCwd && startsWithCwd) {
|
||||
currentConfig.SYSTEM_PROMPT_FILE_PATH = p;
|
||||
} else {
|
||||
logger.warn(`[UI API] Rejected SYSTEM_PROMPT_FILE_PATH traversal attempt: ${p}`);
|
||||
}
|
||||
}
|
||||
if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE;
|
||||
|
|
@ -174,11 +183,20 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
|||
// 防止路径遍历:解析后的绝对路径必须在工作目录内
|
||||
const resolved = path.resolve(process.cwd(), p);
|
||||
const cwd = process.cwd();
|
||||
// Windows兼容:统一使用正斜杠进行比较
|
||||
const normalizedResolved = resolved.replace(/\\/g, '/');
|
||||
const normalizedCwd = cwd.replace(/\\/g, '/');
|
||||
if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) {
|
||||
|
||||
// 使用 path.relative 和 path.isAbsolute 进行更严格的校验
|
||||
const relativePath = path.relative(cwd, resolved);
|
||||
const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..';
|
||||
|
||||
// Windows 大小写不敏感兼容:统一转换为小写比较
|
||||
const normalizedResolved = resolved.toLowerCase().replace(/\\/g, '/');
|
||||
const normalizedCwd = cwd.toLowerCase().replace(/\\/g, '/');
|
||||
const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd;
|
||||
|
||||
if (isInsideCwd && startsWithCwd) {
|
||||
currentConfig.LOG_DIR = p;
|
||||
} else {
|
||||
logger.warn(`[UI API] Rejected LOG_DIR traversal attempt: ${p}`);
|
||||
}
|
||||
}
|
||||
if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID;
|
||||
|
|
@ -188,39 +206,45 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
|||
|
||||
// Scheduled Health Check settings
|
||||
if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) {
|
||||
const incoming = newConfig.SCHEDULED_HEALTH_CHECK;
|
||||
const newInterval = (() => {
|
||||
const val = Number(incoming?.interval);
|
||||
return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, val);
|
||||
})();
|
||||
currentConfig.SCHEDULED_HEALTH_CHECK = {
|
||||
enabled: incoming?.enabled === true,
|
||||
startupRun: incoming?.startupRun !== false,
|
||||
interval: newInterval,
|
||||
providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : []
|
||||
};
|
||||
const incoming = newConfig.SCHEDULED_HEALTH_CHECK;
|
||||
|
||||
// 检测 enabled 状态变化
|
||||
const wasEnabled = currentConfig.SCHEDULED_HEALTH_CHECK?.enabled === true;
|
||||
const nowEnabled = incoming?.enabled === true;
|
||||
// 检测 enabled 状态变化(在更新配置之前保存旧状态)
|
||||
const prevConfig = currentConfig.SCHEDULED_HEALTH_CHECK || {};
|
||||
const wasEnabled = prevConfig.enabled === true;
|
||||
const nowEnabled = incoming?.enabled === true;
|
||||
|
||||
if (currentConfig.SCHEDULED_HEALTH_CHECK) {
|
||||
// 当 enabled 从 true -> false 时,清除 timer
|
||||
if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) {
|
||||
globalThis.stopHealthCheckTimer();
|
||||
globalThis._activeHealthCheckInterval = undefined;
|
||||
}
|
||||
// 当 enabled 从 false -> true 时,启动 timer
|
||||
else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) {
|
||||
globalThis._activeHealthCheckInterval = newInterval;
|
||||
globalThis.reloadHealthCheckTimer(newInterval);
|
||||
}
|
||||
// 当 enabled=true 且 interval 变化时,重启 timer
|
||||
else if (nowEnabled && newInterval !== globalThis._activeHealthCheckInterval) {
|
||||
globalThis._activeHealthCheckInterval = newInterval;
|
||||
globalThis.reloadHealthCheckTimer(newInterval);
|
||||
}
|
||||
}
|
||||
const newInterval = (() => {
|
||||
const val = Number(incoming?.interval);
|
||||
return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, Math.min(HEALTH_CHECK.MAX_INTERVAL_MS, val));
|
||||
})();
|
||||
|
||||
// 先保存旧的 interval 用于比较
|
||||
const oldInterval = globalThis._activeHealthCheckInterval;
|
||||
|
||||
// 更新配置
|
||||
currentConfig.SCHEDULED_HEALTH_CHECK = {
|
||||
enabled: nowEnabled,
|
||||
startupRun: incoming?.startupRun !== false,
|
||||
interval: newInterval,
|
||||
providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : []
|
||||
};
|
||||
|
||||
// 处理 timer 状态变化
|
||||
// 当 enabled 从 true -> false 时,清除 timer
|
||||
if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) {
|
||||
globalThis.stopHealthCheckTimer();
|
||||
globalThis._activeHealthCheckInterval = undefined;
|
||||
}
|
||||
// 当 enabled 从 false -> true 时,启动 timer
|
||||
else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) {
|
||||
globalThis._activeHealthCheckInterval = newInterval;
|
||||
globalThis.reloadHealthCheckTimer(newInterval);
|
||||
}
|
||||
// 当 enabled=true 且 interval 变化时,重启 timer
|
||||
else if (nowEnabled && newInterval !== oldInterval && globalThis.reloadHealthCheckTimer) {
|
||||
globalThis._activeHealthCheckInterval = newInterval;
|
||||
globalThis.reloadHealthCheckTimer(newInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle system prompt update
|
||||
|
|
|
|||
|
|
@ -7,18 +7,22 @@ import { broadcastEvent } from './event-broadcast.js';
|
|||
import { getRegisteredProviders } from '../providers/adapter.js';
|
||||
|
||||
// 文件级互斥锁:防止并发读写导致数据丢失
|
||||
// HTML 脱敏:移除用户输入字段中的 HTML/JS,防止 XSS
|
||||
// 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等),
|
||||
// 存储原始文本。HTML 转义统一由前端 escHtml() 负责,避免双编码问题。
|
||||
function sanitizeProviderData(provider) {
|
||||
if (!provider || typeof provider !== 'object') return provider;
|
||||
const sanitized = { ...provider };
|
||||
// 允许在前端显示的纯文本字段做 HTML 转义
|
||||
if (typeof sanitized.customName === 'string') {
|
||||
sanitized.customName = sanitized.customName
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
let name = sanitized.customName;
|
||||
// 拒绝包含 data: 协议(可能包含内嵌恶意内容)
|
||||
if (/data\s*:/i.test(name)) return sanitized;
|
||||
// 移除 <script>...</script>(支持跨行匹配)
|
||||
name = name.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
// 移除 HTML 事件处理器属性(onclick/onerror 等)
|
||||
name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
||||
// 移除 javascript: 协议(更严格:要求独立单词边界,防止误匹配 "not javascript code")
|
||||
name = name.replace(/\bjavascript\s*:/gi, '');
|
||||
sanitized.customName = name.trim();
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
|
@ -39,9 +43,9 @@ function withFileLock(fn) {
|
|||
const next = _fileLockChain
|
||||
.then(() => fn())
|
||||
.catch(err => {
|
||||
// 记录错误但继续链式执行,防止死锁
|
||||
// 记录错误并抛出,中断操作
|
||||
logger.error('[FileLock] Operation failed:', err?.message || err);
|
||||
return null;
|
||||
throw err;
|
||||
});
|
||||
_fileLockChain = next.then(() => {}).catch(() => {});
|
||||
return next;
|
||||
|
|
@ -133,7 +137,11 @@ export async function handleGetProviderTypeModels(req, res, providerType) {
|
|||
* 添加新的提供商配置
|
||||
*/
|
||||
export async function handleAddProvider(req, res, currentConfig, providerPoolManager) {
|
||||
return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager));
|
||||
return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)).catch(err => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
async function _handleAddProvider(req, res, currentConfig, providerPoolManager) {
|
||||
try {
|
||||
|
|
@ -223,7 +231,11 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
|
|||
* 更新特定提供商配置
|
||||
*/
|
||||
export async function handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid));
|
||||
return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)).catch(err => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
async function _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
try {
|
||||
|
|
@ -312,7 +324,11 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
|
|||
* 删除特定提供商配置
|
||||
*/
|
||||
export async function handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid));
|
||||
return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)).catch(err => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
async function _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
try {
|
||||
|
|
@ -386,7 +402,11 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage
|
|||
* 禁用/启用特定提供商配置
|
||||
*/
|
||||
export async function handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) {
|
||||
return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action));
|
||||
return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action)).catch(err => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
async function _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ export const HEALTH_CHECK = {
|
|||
|
||||
// 密码安全相关常量
|
||||
export const PASSWORD = {
|
||||
// 最小密码长度
|
||||
MIN_LENGTH: 8,
|
||||
// PBKDF2迭代次数
|
||||
PBKDF2_ITERATIONS: 100000,
|
||||
// 最小密码长度(最少12位,与现代安全实践一致)
|
||||
MIN_LENGTH: 12,
|
||||
// PBKDF2迭代次数(OWASP 2023建议 SHA-512 ≥310,000次)
|
||||
PBKDF2_ITERATIONS: 310000,
|
||||
// PBKDF2密钥长度(字节)
|
||||
PBKDF2_KEYLEN: 64,
|
||||
// PBKDF2哈希算法
|
||||
|
|
@ -38,7 +38,5 @@ export const NETWORK = {
|
|||
// 请求重试相关常量
|
||||
export const RETRY = {
|
||||
// 最大重试次数
|
||||
MAX_RETRIES: 100,
|
||||
// 默认重试次数
|
||||
DEFAULT_RETRIES: 3
|
||||
MAX_RETRIES: 100
|
||||
};
|
||||
|
|
|
|||
|
|
@ -94,10 +94,19 @@ export const PROVIDER_MAPPINGS = [
|
|||
|
||||
/**
|
||||
* 生成 UUID
|
||||
* 兼容旧版 Node.js(<14.17.0):如果 crypto.randomUUID 不存在则使用 Math.random 回退方案
|
||||
* @returns {string} UUID 字符串
|
||||
*/
|
||||
export function generateUUID() {
|
||||
return crypto.randomUUID();
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// 回退方案:使用 Math.random 生成标准 UUID v4
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1195,6 +1195,13 @@ const translations = {
|
|||
'config.proxy.tlsSidecarProxyUrl': 'Sidecar Upstream Proxy',
|
||||
'config.proxy.tlsSidecarEnabledProviders': 'Providers Using TLS Sidecar',
|
||||
'config.proxy.tlsSidecarNote': 'When enabled, requests for selected providers are routed through Go uTLS sidecar for perfect Chrome TLS/H2 fingerprint to bypass Cloudflare (requires restart)',
|
||||
'config.healthCheck.title': 'Scheduled Health Check',
|
||||
'config.healthCheck.enabled': 'Enable Scheduled Check',
|
||||
'config.healthCheck.startupRun': 'Run on Startup',
|
||||
'config.healthCheck.interval': 'Check Interval',
|
||||
'config.healthCheck.intervalNote': 'In milliseconds. Minimum 60000ms (1 min), maximum 3600000ms (1 hour). Enter manually or use quick select buttons',
|
||||
'config.healthCheck.providerTypes': 'Providers to Check',
|
||||
'config.healthCheck.providerTypesNote': 'Select provider types for scheduled health checks. Leave empty to skip all checks',
|
||||
'config.log.title': 'Log Settings',
|
||||
'config.log.enabled': 'Enable Logging',
|
||||
'config.log.outputMode': 'Log Output Mode',
|
||||
|
|
|
|||
Loading…
Reference in a new issue