feat: add per-node health check action in provider modal
This commit is contained in:
parent
110720982f
commit
a497aaaf19
5 changed files with 235 additions and 4 deletions
|
|
@ -2,7 +2,8 @@ import { convertData } from '../convert/convert.js';
|
||||||
import { MODEL_PROVIDER } from '../utils/common.js';
|
import { MODEL_PROVIDER } from '../utils/common.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider model catalogs used by the Web UI.
|
* 各提供商支持的模型列表
|
||||||
|
* 用于前端UI选择不支持的模型
|
||||||
*/
|
*/
|
||||||
export const PROVIDER_MODELS = {
|
export const PROVIDER_MODELS = {
|
||||||
'gemini-cli-oauth': [
|
'gemini-cli-oauth': [
|
||||||
|
|
@ -49,7 +50,9 @@ export const PROVIDER_MODELS = {
|
||||||
'vision-model'
|
'vision-model'
|
||||||
],
|
],
|
||||||
'openai-iflow': [
|
'openai-iflow': [
|
||||||
|
// iFlow 特有模型
|
||||||
'iflow-rome-30ba3b',
|
'iflow-rome-30ba3b',
|
||||||
|
// Qwen 模型
|
||||||
'qwen3-coder-plus',
|
'qwen3-coder-plus',
|
||||||
'qwen3-max',
|
'qwen3-max',
|
||||||
'qwen3-vl-plus',
|
'qwen3-vl-plus',
|
||||||
|
|
@ -58,12 +61,16 @@ export const PROVIDER_MODELS = {
|
||||||
'qwen3-235b-a22b-thinking-2507',
|
'qwen3-235b-a22b-thinking-2507',
|
||||||
'qwen3-235b-a22b-instruct',
|
'qwen3-235b-a22b-instruct',
|
||||||
'qwen3-235b',
|
'qwen3-235b',
|
||||||
|
// Kimi 模型
|
||||||
'kimi-k2-0905',
|
'kimi-k2-0905',
|
||||||
'kimi-k2',
|
'kimi-k2',
|
||||||
|
// GLM 模型
|
||||||
'glm-4.6',
|
'glm-4.6',
|
||||||
|
// DeepSeek 模型
|
||||||
'deepseek-v3.2',
|
'deepseek-v3.2',
|
||||||
'deepseek-r1',
|
'deepseek-r1',
|
||||||
'deepseek-v3',
|
'deepseek-v3',
|
||||||
|
// 手动定义
|
||||||
'glm-4.7',
|
'glm-4.7',
|
||||||
'glm-5',
|
'glm-5',
|
||||||
'kimi-k2.5',
|
'kimi-k2.5',
|
||||||
|
|
@ -181,15 +188,16 @@ export function getConfiguredSupportedModels(providerType, providerConfig = {})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets models supported by a provider type.
|
* 获取指定提供商类型支持的模型列表
|
||||||
* @param {string} providerType
|
* @param {string} providerType - 提供商类型
|
||||||
* @returns {Array<string>}
|
* @returns {Array<string>} 模型列表
|
||||||
*/
|
*/
|
||||||
export function getProviderModels(providerType) {
|
export function getProviderModels(providerType) {
|
||||||
if (PROVIDER_MODELS[providerType]) {
|
if (PROVIDER_MODELS[providerType]) {
|
||||||
return PROVIDER_MODELS[providerType];
|
return PROVIDER_MODELS[providerType];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尝试前缀匹配 (例如 openai-custom-1 -> openai-custom)
|
||||||
for (const key of Object.keys(PROVIDER_MODELS)) {
|
for (const key of Object.keys(PROVIDER_MODELS)) {
|
||||||
if (providerType.startsWith(key + '-')) {
|
if (providerType.startsWith(key + '-')) {
|
||||||
return PROVIDER_MODELS[key];
|
return PROVIDER_MODELS[key];
|
||||||
|
|
@ -199,6 +207,10 @@ export function getProviderModels(providerType) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有提供商的模型列表
|
||||||
|
* @returns {Object} 所有提供商的模型映射
|
||||||
|
*/
|
||||||
export function getAllProviderModels() {
|
export function getAllProviderModels() {
|
||||||
return PROVIDER_MODELS;
|
return PROVIDER_MODELS;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,14 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
||||||
return await providerApi.handleDetectProviderModels(req, res, currentConfig, providerPoolManager, providerType, providerUuid);
|
return await providerApi.handleDetectProviderModels(req, res, currentConfig, providerPoolManager, providerType, providerUuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform health check for a specific provider node
|
||||||
|
const singleHealthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/health-check$/);
|
||||||
|
if (method === 'POST' && singleHealthCheckMatch) {
|
||||||
|
const providerType = decodeURIComponent(singleHealthCheckMatch[1]);
|
||||||
|
const providerUuid = singleHealthCheckMatch[2];
|
||||||
|
return await providerApi.handleSingleProviderHealthCheck(req, res, currentConfig, providerPoolManager, providerType, providerUuid);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete all unhealthy providers for a specific type
|
// Delete all unhealthy providers for a specific type
|
||||||
// NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'delete-unhealthy' as UUID
|
// NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'delete-unhealthy' as UUID
|
||||||
const deleteUnhealthyMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/delete-unhealthy$/);
|
const deleteUnhealthyMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/delete-unhealthy$/);
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,86 @@ function getManagedSupportedModels(providerType, providers = []) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistProviderStatusToFile(currentConfig, providerPoolManager) {
|
||||||
|
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||||
|
const providerPools = {};
|
||||||
|
|
||||||
|
for (const providerType in providerPoolManager.providerStatus) {
|
||||||
|
providerPools[providerType] = providerPoolManager.providerStatus[providerType].map(providerStatus => providerStatus.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8');
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthHealthCheckError(errorMessage = '') {
|
||||||
|
return /\b(401|403)\b/.test(errorMessage) ||
|
||||||
|
/\b(Unauthorized|Forbidden|AccessDenied|InvalidToken|ExpiredToken)\b/i.test(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProviderHealthCheck(providerPoolManager, providerType, providerStatus) {
|
||||||
|
const providerConfig = providerStatus.config;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig);
|
||||||
|
|
||||||
|
if (healthResult.success) {
|
||||||
|
providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName);
|
||||||
|
return {
|
||||||
|
uuid: providerConfig.uuid,
|
||||||
|
success: true,
|
||||||
|
healthy: true,
|
||||||
|
modelName: healthResult.modelName,
|
||||||
|
message: 'Healthy'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = healthResult.errorMessage || 'Check failed';
|
||||||
|
const isAuthError = isAuthHealthCheckError(errorMessage);
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
providerPoolManager.markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage);
|
||||||
|
logger.info(`[UI API] Auth error detected for ${providerConfig.uuid}, immediately marked as unhealthy`);
|
||||||
|
} else {
|
||||||
|
providerPoolManager.markProviderUnhealthy(providerType, providerConfig, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
providerStatus.config.lastHealthCheckTime = new Date().toISOString();
|
||||||
|
if (healthResult.modelName) {
|
||||||
|
providerStatus.config.lastHealthCheckModel = healthResult.modelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: providerConfig.uuid,
|
||||||
|
success: false,
|
||||||
|
healthy: false,
|
||||||
|
modelName: healthResult.modelName,
|
||||||
|
message: errorMessage,
|
||||||
|
isAuthError
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error.message || 'Unknown error';
|
||||||
|
const isAuthError = isAuthHealthCheckError(errorMessage);
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
providerPoolManager.markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage);
|
||||||
|
logger.info(`[UI API] Auth error detected for ${providerConfig.uuid}, immediately marked as unhealthy`);
|
||||||
|
} else {
|
||||||
|
providerPoolManager.markProviderUnhealthy(providerType, providerConfig, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
providerStatus.config.lastHealthCheckTime = new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: providerConfig.uuid,
|
||||||
|
success: false,
|
||||||
|
healthy: false,
|
||||||
|
message: errorMessage,
|
||||||
|
isAuthError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 Promise 链式队列,确保文件操作顺序执行
|
// 使用 Promise 链式队列,确保文件操作顺序执行
|
||||||
let _fileLockChain = Promise.resolve();
|
let _fileLockChain = Promise.resolve();
|
||||||
|
|
||||||
|
|
@ -1144,6 +1224,59 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
|
||||||
* 快速链接配置文件到对应的提供商
|
* 快速链接配置文件到对应的提供商
|
||||||
* 支持单个文件路径或文件路径数组
|
* 支持单个文件路径或文件路径数组
|
||||||
*/
|
*/
|
||||||
|
export async function handleSingleProviderHealthCheck(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||||
|
try {
|
||||||
|
if (!providerPoolManager) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = providerPoolManager.providerStatus[providerType] || [];
|
||||||
|
const providerStatus = providers.find(item => item.config?.uuid === providerUuid);
|
||||||
|
|
||||||
|
if (!providerStatus) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'Provider not found' } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[UI API] Starting single health check for provider ${providerUuid} in ${providerType}`);
|
||||||
|
|
||||||
|
const result = await runProviderHealthCheck(providerPoolManager, providerType, providerStatus);
|
||||||
|
const filePath = persistProviderStatusToFile(currentConfig, providerPoolManager);
|
||||||
|
|
||||||
|
broadcastEvent('config_update', {
|
||||||
|
action: 'health_check_single',
|
||||||
|
filePath,
|
||||||
|
providerType,
|
||||||
|
providerUuid,
|
||||||
|
result: {
|
||||||
|
...result,
|
||||||
|
message: sanitizeProviderData({ message: result.message }).message
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
providerType,
|
||||||
|
uuid: providerUuid,
|
||||||
|
healthy: result.healthy,
|
||||||
|
modelName: result.modelName || null,
|
||||||
|
message: result.message,
|
||||||
|
isAuthError: result.isAuthError || false
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[UI API] Single health check error:', error);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: error.message } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleQuickLinkProvider(req, res, currentConfig, providerPoolManager) {
|
export async function handleQuickLinkProvider(req, res, currentConfig, providerPoolManager) {
|
||||||
try {
|
try {
|
||||||
const body = await getRequestBody(req);
|
const body = await getRequestBody(req);
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,11 @@ const translations = {
|
||||||
'modal.provider.refreshUnhealthyUuids.failed': '刷新失败',
|
'modal.provider.refreshUnhealthyUuids.failed': '刷新失败',
|
||||||
'modal.provider.kiroAuthHint': '使用 AWS Builder ID 登录方式时,需要 <code>clientId</code> 和 <code>clientSecret</code> 字段,可在同文件夹下的另一个 JSON 文件中获取',
|
'modal.provider.kiroAuthHint': '使用 AWS Builder ID 登录方式时,需要 <code>clientId</code> 和 <code>clientSecret</code> 字段,可在同文件夹下的另一个 JSON 文件中获取',
|
||||||
|
|
||||||
|
'modal.provider.healthCheckCurrentTitle': '对当前节点立即执行一次健康检查',
|
||||||
|
'modal.provider.healthCheckSingleSuccess': '健康检查通过',
|
||||||
|
'modal.provider.healthCheckSingleSuccessWithModel': '健康检查通过,使用模型: {model}',
|
||||||
|
'modal.provider.healthCheckSingleFailed': '健康检查失败: {message}',
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
'pagination.showing': '显示 {start}-{end} / 共 {total} 条',
|
'pagination.showing': '显示 {start}-{end} / 共 {total} 条',
|
||||||
'pagination.jumpTo': '跳转到',
|
'pagination.jumpTo': '跳转到',
|
||||||
|
|
@ -1474,6 +1479,10 @@ const translations = {
|
||||||
'modal.provider.refreshUnhealthyUuids.success': 'Refreshed {count} UUID(s)',
|
'modal.provider.refreshUnhealthyUuids.success': 'Refreshed {count} UUID(s)',
|
||||||
'modal.provider.refreshUnhealthyUuids.failed': 'Refresh failed',
|
'modal.provider.refreshUnhealthyUuids.failed': 'Refresh failed',
|
||||||
'modal.provider.kiroAuthHint': 'When using AWS Builder ID login, <code>clientId</code> and <code>clientSecret</code> fields are required, which can be found in another JSON file in the same folder',
|
'modal.provider.kiroAuthHint': 'When using AWS Builder ID login, <code>clientId</code> and <code>clientSecret</code> fields are required, which can be found in another JSON file in the same folder',
|
||||||
|
'modal.provider.healthCheckCurrentTitle': 'Run a health check for this node now',
|
||||||
|
'modal.provider.healthCheckSingleSuccess': 'Health check passed',
|
||||||
|
'modal.provider.healthCheckSingleSuccessWithModel': 'Health check passed using model: {model}',
|
||||||
|
'modal.provider.healthCheckSingleFailed': 'Health check failed: {message}',
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
'pagination.showing': 'Showing {start}-{end} of {total}',
|
'pagination.showing': 'Showing {start}-{end} of {total}',
|
||||||
|
|
|
||||||
|
|
@ -796,6 +796,9 @@ function renderProviderList(providers) {
|
||||||
<button class="btn-small btn-edit" onclick="window.editProvider('${provider.uuid}', event)">
|
<button class="btn-small btn-edit" onclick="window.editProvider('${provider.uuid}', event)">
|
||||||
<i class="fas fa-edit"></i> <span data-i18n="modal.provider.edit">编辑</span>
|
<i class="fas fa-edit"></i> <span data-i18n="modal.provider.edit">编辑</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-small btn-info btn-provider-health-check" onclick="window.performSingleHealthCheck('${provider.uuid}', event)" title="${t('modal.provider.healthCheckCurrentTitle')}">
|
||||||
|
<i class="fas fa-stethoscope"></i> <span data-i18n="modal.provider.healthCheck">${t('modal.provider.healthCheck')}</span>
|
||||||
|
</button>
|
||||||
<button class="btn-small btn-delete" onclick="window.deleteProvider('${provider.uuid}', event)">
|
<button class="btn-small btn-delete" onclick="window.deleteProvider('${provider.uuid}', event)">
|
||||||
<i class="fas fa-trash"></i> <span data-i18n="modal.provider.delete">删除</span>
|
<i class="fas fa-trash"></i> <span data-i18n="modal.provider.delete">删除</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1268,6 +1271,9 @@ function cancelEdit(uuid, event) {
|
||||||
<button class="btn-small btn-edit" onclick="window.editProvider('${uuid}', event)">
|
<button class="btn-small btn-edit" onclick="window.editProvider('${uuid}', event)">
|
||||||
<i class="fas fa-edit"></i> <span data-i18n="modal.provider.edit">${t('modal.provider.edit')}</span>
|
<i class="fas fa-edit"></i> <span data-i18n="modal.provider.edit">${t('modal.provider.edit')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-small btn-info btn-provider-health-check" onclick="window.performSingleHealthCheck('${uuid}', event)" title="${t('modal.provider.healthCheckCurrentTitle')}">
|
||||||
|
<i class="fas fa-stethoscope"></i> <span data-i18n="modal.provider.healthCheck">${t('modal.provider.healthCheck')}</span>
|
||||||
|
</button>
|
||||||
<button class="btn-small btn-delete" onclick="window.deleteProvider('${uuid}', event)">
|
<button class="btn-small btn-delete" onclick="window.deleteProvider('${uuid}', event)">
|
||||||
<i class="fas fa-trash"></i> <span data-i18n="modal.provider.delete">${t('modal.provider.delete')}</span>
|
<i class="fas fa-trash"></i> <span data-i18n="modal.provider.delete">${t('modal.provider.delete')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1813,6 +1819,67 @@ async function performHealthCheck(providerType) {
|
||||||
* @param {string} uuid - 提供商UUID
|
* @param {string} uuid - 提供商UUID
|
||||||
* @param {Event} event - 事件对象
|
* @param {Event} event - 事件对象
|
||||||
*/
|
*/
|
||||||
|
async function performSingleHealthCheck(uuid, event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const button = event.currentTarget || event.target.closest('button');
|
||||||
|
const providerDetail = event.target.closest('.provider-item-detail');
|
||||||
|
const providerType = providerDetail?.closest('.provider-modal')?.getAttribute('data-provider-type');
|
||||||
|
|
||||||
|
if (!providerDetail || !providerType) {
|
||||||
|
showToast(t('common.error'), t('modal.provider.healthCheckSingleFailed', { message: t('common.error') }), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalHtml = button ? button.innerHTML : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i> <span>${t('modal.provider.healthCheck')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(t('common.info'), t('modal.provider.healthCheck') + '...', 'info');
|
||||||
|
|
||||||
|
const response = await window.apiClient.post(
|
||||||
|
`/providers/${encodeURIComponent(providerType)}/${uuid}/health-check`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
showToast(t('common.error'), t('modal.provider.healthCheckSingleFailed', { message: t('common.error') }), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = response.healthy
|
||||||
|
? (response.modelName
|
||||||
|
? t('modal.provider.healthCheckSingleSuccessWithModel', { model: response.modelName })
|
||||||
|
: t('modal.provider.healthCheckSingleSuccess'))
|
||||||
|
: t('modal.provider.healthCheckSingleFailed', { message: response.message || t('common.error') });
|
||||||
|
|
||||||
|
showToast(
|
||||||
|
response.healthy ? t('common.success') : t('common.warning'),
|
||||||
|
message,
|
||||||
|
response.healthy ? 'success' : 'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
await window.apiClient.post('/reload-config');
|
||||||
|
await refreshProviderConfig(providerType);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Single provider health check failed:', error);
|
||||||
|
showToast(
|
||||||
|
t('common.error'),
|
||||||
|
t('modal.provider.healthCheckSingleFailed', { message: error.message }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (button && button.isConnected) {
|
||||||
|
button.innerHTML = originalHtml;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshProviderUuid(uuid, event) {
|
async function refreshProviderUuid(uuid, event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
|
@ -1993,6 +2060,7 @@ export {
|
||||||
loadModelsForProviderType,
|
loadModelsForProviderType,
|
||||||
renderNotSupportedModelsSelector,
|
renderNotSupportedModelsSelector,
|
||||||
goToProviderPage,
|
goToProviderPage,
|
||||||
|
performSingleHealthCheck,
|
||||||
refreshProviderUuid
|
refreshProviderUuid
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2008,6 +2076,7 @@ window.addProvider = addProvider;
|
||||||
window.toggleProviderStatus = toggleProviderStatus;
|
window.toggleProviderStatus = toggleProviderStatus;
|
||||||
window.resetAllProvidersHealth = resetAllProvidersHealth;
|
window.resetAllProvidersHealth = resetAllProvidersHealth;
|
||||||
window.performHealthCheck = performHealthCheck;
|
window.performHealthCheck = performHealthCheck;
|
||||||
|
window.performSingleHealthCheck = performSingleHealthCheck;
|
||||||
window.deleteUnhealthyProviders = deleteUnhealthyProviders;
|
window.deleteUnhealthyProviders = deleteUnhealthyProviders;
|
||||||
window.refreshUnhealthyUuids = refreshUnhealthyUuids;
|
window.refreshUnhealthyUuids = refreshUnhealthyUuids;
|
||||||
window.openSupportedModelsPicker = openSupportedModelsPicker;
|
window.openSupportedModelsPicker = openSupportedModelsPicker;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue