feat(ui): 增强提供商刷新状态显示和版本选择功能

- 在提供商管理界面添加刷新状态徽章,显示“刷新中”状态
- 为更新功能添加版本选择下拉框,支持选择特定版本进行更新
- 在提供商状态中新增 needsRefresh 字段用于跟踪刷新状态
- 修复冷启动时刷新状态重置逻辑,避免持久化状态影响新会话
- 为刷新操作添加超时保护机制,防止适配器调用无限挂起
- 完善国际化翻译,添加相关状态和版本标签
This commit is contained in:
hex2077 2026-04-05 21:46:05 +08:00
parent 85d7b50cb1
commit 1ee4ca37d1
9 changed files with 201 additions and 74 deletions

View file

@ -72,6 +72,7 @@ export class ProviderPoolManager {
this.refreshBufferQueues = {}; // 按 providerType 分组的缓冲队列
this.refreshBufferTimers = {}; // 按 providerType 分组的定时器
this.bufferDelay = options.globalConfig?.REFRESH_BUFFER_DELAY ?? 5000; // 默认5秒缓冲延迟
this.refreshTaskTimeoutMs = options.globalConfig?.REFRESH_TASK_TIMEOUT_MS ?? 60000; // 默认60秒刷新超时
// 用于并发选点时的原子排序辅助(自增序列)
this._selectionSequence = 0;
@ -184,6 +185,12 @@ export class ProviderPoolManager {
_enqueueRefresh(providerType, providerStatus, force = false) {
const uuid = providerStatus.uuid;
// 如果节点被禁用,不进行刷新
if (providerStatus.config.isDisabled) {
this._log('debug', `Skipping refresh for disabled node ${uuid}`);
return;
}
// 如果已经在刷新中,直接返回
if (this.refreshingUuids.has(uuid)) {
this._log('debug', `Node ${uuid} is already in refresh queue.`);
@ -405,16 +412,18 @@ export class ProviderPoolManager {
// 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑)
if (typeof serviceAdapter.refreshToken === 'function') {
const startTime = Date.now();
let refreshOperation;
if (force) {
if (typeof serviceAdapter.forceRefreshToken === 'function') {
await serviceAdapter.forceRefreshToken();
refreshOperation = serviceAdapter.forceRefreshToken();
} else {
this._log('warn', `forceRefreshToken not implemented for ${providerType}, falling back to refreshToken`);
await serviceAdapter.refreshToken();
refreshOperation = serviceAdapter.refreshToken();
}
} else {
await serviceAdapter.refreshToken();
refreshOperation = serviceAdapter.refreshToken();
}
await this._awaitRefreshWithTimeout(refreshOperation, providerType, providerStatus.uuid);
const duration = Date.now() - startTime;
this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`);
@ -422,6 +431,8 @@ export class ProviderPoolManager {
config.needsRefresh = false;
config.refreshCount = 0;
config.lastRefreshTime = Date.now(); // 记录最后刷新成功时间
this._debouncedSave(providerType);
} else {
throw new Error(`refreshToken method not implemented for ${providerType}`);
}
@ -433,6 +444,31 @@ export class ProviderPoolManager {
}
}
/**
* 为刷新任务附加超时保护避免单个适配器调用无限挂起
* @private
*/
async _awaitRefreshWithTimeout(refreshOperation, providerType, uuid) {
if (this.refreshTaskTimeoutMs <= 0) {
return await refreshOperation;
}
let timeoutId = null;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Refresh timeout after ${this.refreshTaskTimeoutMs}ms for node ${uuid} (${providerType})`));
}, this.refreshTaskTimeoutMs);
});
try {
return await Promise.race([Promise.resolve(refreshOperation), timeoutPromise]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
/**
* 计算节点的权重/评分用于排序
* 分数越低优先级越高
@ -627,6 +663,7 @@ export class ProviderPoolManager {
*/
initializeProviderStatus() {
const oldFullStatus = this.providerStatus || {};
const isColdStart = Object.keys(oldFullStatus).length === 0;
this.providerStatus = {}; // Tracks health and usage for each provider instance
for (const providerType in this.providerPools) {
const oldStatus = oldFullStatus[providerType] || [];
@ -652,8 +689,13 @@ export class ProviderPoolManager {
providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0;
// --- V2: 刷新监控字段 ---
providerConfig.needsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false;
providerConfig.refreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0;
const persistedNeedsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false;
const persistedRefreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0;
if (isColdStart && (persistedNeedsRefresh || persistedRefreshCount > 0)) {
this._log('info', `Resetting stale refresh state for provider ${providerConfig.uuid} (${providerType}) on startup.`);
}
providerConfig.needsRefresh = isColdStart ? false : persistedNeedsRefresh;
providerConfig.refreshCount = isColdStart ? 0 : persistedRefreshCount;
// 优化2: 简化 lastErrorTime 处理逻辑
providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date
@ -680,6 +722,9 @@ export class ProviderPoolManager {
logger.error(`[ProviderPoolManager] Error initializing node for ${providerType}: ${nodeError.message}`);
}
});
// 确保初始化时的默认值补全也能写盘
this._debouncedSave(providerType);
}
this._log('info', `Initialized provider statuses: ok (maxErrorCount: ${this.maxErrorCount})`);
}

View file

@ -563,7 +563,8 @@ export async function getProviderStatus(config, options = {}) {
'customName',
'isHealthy',
'lastErrorTime',
'lastErrorMessage'
'lastErrorMessage',
'needsRefresh'
];
// identify 字段映射表
const identifyFieldMap = {

View file

@ -18,7 +18,7 @@ function sanitizeProviderData(provider, maskSensitive = false) {
if (maskSensitive) {
for (const key in sanitized) {
// 排除已知非敏感字段
if (key === 'uuid' || key === 'customName' || key === 'isHealthy' || key === 'isDisabled') continue;
if (key === 'uuid' || key === 'customName' || key === 'isHealthy' || key === 'isDisabled' || key === 'needsRefresh') continue;
const val = sanitized[key];
if (typeof val !== 'string' || !val) continue;
@ -138,8 +138,18 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa
const poolsData = JSON.parse(readFileSync(filePath, 'utf-8'));
poolTypes = Object.keys(poolsData);
poolTypes.forEach(type => {
if (!providerStatus[type]) {
providerStatus[type] = [];
// 如果管理器中没有该组,或者该组是空的,则从文件中补全
if (!providerStatus[type] || providerStatus[type].length === 0) {
const fileProviders = poolsData[type] || [];
if (fileProviders.length > 0) {
providerStatus[type] = fileProviders.map(p => ({
...p,
activeRequests: 0,
waitingRequests: 0
}));
} else if (!providerStatus[type]) {
providerStatus[type] = [];
}
}
});
}

View file

@ -6,6 +6,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import { CONFIG } from '../core/config-manager.js';
import { parseProxyUrl } from '../utils/proxy-utils.js';
import { getRequestBody } from '../utils/common.js';
const execAsync = promisify(exec);
const GITHUB_REPO = 'justlovemaki/AIClient-2-API';
@ -149,16 +150,16 @@ function compareVersions(v1, v2) {
}
/**
* 通过 GitHub API 获取最新版本
* @returns {Promise<string|null>} 最新版本号或 null
* 通过 GitHub API 获取最近的版本列表
* @param {number} limit - 限制返回的版本数量
* @returns {Promise<string[]>} 版本列表
*/
async function getLatestVersionFromGitHub() {
async function getVersionsFromGitHub(limit = 10) {
const candidates = buildGitHubApiCandidates(GITHUB_REPO);
for (const candidate of candidates) {
try {
logger.info(`[Update] Fetching latest version from GitHub API via ${candidate.name}...`);
logger.info(`[Update] Request URL: ${candidate.url}`);
logger.info(`[Update] Fetching versions from GitHub API via ${candidate.name}...`);
const response = await fetchWithProxy(candidate.url, {
headers: {
'Accept': 'application/vnd.github.v3+json',
@ -189,15 +190,23 @@ async function getLatestVersionFromGitHub() {
}
versions.sort((a, b) => compareVersions(b, a));
logger.info(`[Update] Latest version fetched successfully via ${candidate.name}: ${versions[0]}`);
return versions[0];
return versions.slice(0, limit);
} catch (error) {
logger.warn(`[Update] Failed to fetch latest version via ${candidate.name}: ${error.message}`);
logger.warn(`[Update] Failed to fetch versions via ${candidate.name}: ${error.message}`);
}
}
logger.warn('[Update] All GitHub API proxy attempts failed');
return null;
return [];
}
/**
* 通过 GitHub API 获取最新版本
* @returns {Promise<string|null>} 最新版本号或 null
*/
async function getLatestVersionFromGitHub() {
const versions = await getVersionsFromGitHub(1);
return versions.length > 0 ? versions[0] : null;
}
/**
@ -231,10 +240,11 @@ export async function checkForUpdates() {
}
let latestTag = null;
let availableVersions = [];
let updateMethod = 'unknown';
if (isGitRepo) {
// Git 仓库模式:使用 git 命令
// Git 仓库模式:使用 git命令
updateMethod = 'git';
// 获取远程 tags
@ -244,45 +254,33 @@ export async function checkForUpdates() {
} catch (error) {
logger.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message);
// 如果 git fetch 失败,回退到 GitHub API
latestTag = await getLatestVersionFromGitHub();
availableVersions = await getVersionsFromGitHub(10);
latestTag = availableVersions.length > 0 ? availableVersions[0] : null;
updateMethod = 'github_api';
}
// 如果 git fetch 成功,获取最新的 tag
// 如果 git fetch 成功,获取最新的 tag 和可用的 tags
if (!latestTag && updateMethod === 'git') {
const isWindows = process.platform === 'win32';
try {
if (isWindows) {
// Windows: 使用 git for-each-ref这是跨平台兼容的方式
const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1');
latestTag = stdout.trim();
} else {
// Linux/macOS: 使用 head 命令,更高效
const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1');
latestTag = stdout.trim();
// 获取最近的 10 个 tag
const { stdout } = await execAsync('git tag --sort=-v:refname');
const tags = stdout.trim().split('\n').filter(t => t);
if (tags.length > 0) {
availableVersions = tags.slice(0, 10);
latestTag = availableVersions[0];
}
} catch (error) {
// 备用方案:获取所有 tags 并在 JavaScript 中排序
try {
const { stdout } = await execAsync('git tag');
const tags = stdout.trim().split('\n').filter(t => t);
if (tags.length > 0) {
// 按版本号排序(降序)
tags.sort((a, b) => compareVersions(b, a));
latestTag = tags[0];
}
} catch (e) {
logger.warn('[Update] Failed to get latest tag via git, falling back to GitHub API:', e.message);
latestTag = await getLatestVersionFromGitHub();
updateMethod = 'github_api';
}
logger.warn('[Update] Failed to get tags via git, falling back to GitHub API:', error.message);
availableVersions = await getVersionsFromGitHub(10);
latestTag = availableVersions.length > 0 ? availableVersions[0] : null;
updateMethod = 'github_api';
}
}
} else {
// 非 Git 仓库模式(如 Docker 容器):使用 GitHub API
updateMethod = 'github_api';
latestTag = await getLatestVersionFromGitHub();
availableVersions = await getVersionsFromGitHub(10);
latestTag = availableVersions.length > 0 ? availableVersions[0] : null;
}
if (!latestTag) {
@ -290,6 +288,7 @@ export async function checkForUpdates() {
hasUpdate: false,
localVersion,
latestVersion: null,
availableVersions: [],
updateMethod,
error: 'Unable to get latest version information'
};
@ -305,6 +304,7 @@ export async function checkForUpdates() {
hasUpdate,
localVersion,
latestVersion: latestTag,
availableVersions,
updateMethod,
error: null
};
@ -312,9 +312,10 @@ export async function checkForUpdates() {
/**
* 执行更新操作
* @param {string} targetTag - 目标版本 tag如果未提供则更新到最新版本
* @returns {Promise<Object>} 更新结果
*/
export async function performUpdate() {
export async function performUpdate(targetTag = null) {
// 首先检查是否有更新
const updateInfo = await checkForUpdates();
@ -322,7 +323,12 @@ export async function performUpdate() {
throw new Error(updateInfo.error);
}
if (!updateInfo.hasUpdate) {
// 如果未提供 targetTag使用最新版本
const latestTag = updateInfo.latestVersion;
const finalTag = targetTag || latestTag;
// 如果是更新到最新版本,且当前已是最新版本
if (!targetTag && !updateInfo.hasUpdate) {
return {
success: true,
message: 'Already at the latest version',
@ -332,16 +338,25 @@ export async function performUpdate() {
};
}
const latestTag = updateInfo.latestVersion;
// 如果指定了 tag但与本地版本相同
if (targetTag && (targetTag === updateInfo.localVersion || targetTag === `v${updateInfo.localVersion}`)) {
return {
success: true,
message: `Already at version ${targetTag}`,
localVersion: updateInfo.localVersion,
latestVersion: updateInfo.latestVersion,
updated: false
};
}
// 检查更新方式 - 如果是通过 GitHub API 获取的版本信息,说明不在 Git 仓库中
if (updateInfo.updateMethod === 'github_api') {
// Docker/非 Git 环境,通过下载 tarball 更新
logger.info('[Update] Running in Docker/non-Git environment, will download and extract tarball');
return await performTarballUpdate(updateInfo.localVersion, latestTag);
logger.info(`[Update] Running in Docker/non-Git environment, will download and extract tarball for ${finalTag}`);
return await performTarballUpdate(updateInfo.localVersion, finalTag);
}
logger.info(`[Update] Starting update to ${latestTag}...`);
logger.info(`[Update] Starting update to ${finalTag}...`);
// 检查是否有未提交的更改
try {
@ -355,19 +370,19 @@ export async function performUpdate() {
logger.warn('[Update] Failed to check git status:', error.message);
}
// 执行 checkout 到最新 tag
// 执行 checkout 到目标 tag
try {
logger.info(`[Update] Checking out to ${latestTag}...`);
await execAsync(`git checkout ${latestTag}`);
logger.info(`[Update] Checking out to ${finalTag}...`);
await execAsync(`git checkout ${finalTag}`);
} catch (error) {
logger.error('[Update] Failed to checkout:', error.message);
throw new Error('Failed to switch to new version: ' + error.message);
throw new Error(`Failed to switch to version ${finalTag}: ` + error.message);
}
// 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步)
const versionFilePath = path.join(process.cwd(), 'VERSION');
try {
const newVersion = latestTag.replace(/^v/, '');
const newVersion = finalTag.replace(/^v/, '');
writeFileSync(versionFilePath, newVersion, 'utf-8');
logger.info(`[Update] VERSION file updated to ${newVersion}`);
} catch (error) {
@ -379,7 +394,7 @@ export async function performUpdate() {
try {
// 确保本地版本号有 v 前缀,以匹配 git tag 格式
const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`;
const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${latestTag} --name-only`);
const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${finalTag} --name-only`);
if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) {
logger.info('[Update] package.json changed, running npm install...');
await execAsync('npm install');
@ -389,13 +404,14 @@ export async function performUpdate() {
logger.warn('[Update] Failed to check package changes:', error.message);
}
logger.info(`[Update] Update completed successfully to ${latestTag}`);
logger.info(`[Update] Update completed successfully to ${finalTag}`);
return {
success: true,
message: `Successfully updated to version ${latestTag}`,
message: `Successfully updated to version ${finalTag}`,
localVersion: updateInfo.localVersion,
latestVersion: latestTag,
targetVersion: finalTag,
updated: true,
updateMethod: 'git',
needsRestart: needsRestart,
@ -626,7 +642,10 @@ export async function handleCheckUpdate(req, res) {
*/
export async function handlePerformUpdate(req, res) {
try {
const updateResult = await performUpdate();
const body = await getRequestBody(req);
const version = body?.version || null;
const updateResult = await performUpdate(version);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(updateResult));
return true;
@ -640,4 +659,4 @@ export async function handlePerformUpdate(req, res) {
}));
return true;
}
}
}

View file

@ -47,6 +47,8 @@ const translations = {
'dashboard.update.performTitle': '更新到最新版本',
'dashboard.update.checking': '正在检查...',
'dashboard.update.upToDate': '已是最新',
'dashboard.update.latest': '最新',
'dashboard.update.current': '当前',
'dashboard.update.hasUpdate': '发现新版本: {version}',
'dashboard.update.updating': '正在更新...',
'dashboard.update.success': '更新成功',
@ -464,6 +466,7 @@ const translations = {
'providers.healthyProviders': '健康提供商',
'providers.status.healthy': '{healthy}/{total} 可用',
'providers.status.empty': '0/0 节点',
'providers.status.needsRefresh': '刷新中',
'providers.stat.totalAccounts': '总账户',
'providers.stat.healthyAccounts': '健康账户',
'providers.stat.usageCount': '使用次数',
@ -914,6 +917,8 @@ const translations = {
'dashboard.update.performTitle': 'Update to latest version',
'dashboard.update.checking': 'Checking...',
'dashboard.update.upToDate': 'Up to date',
'dashboard.update.latest': 'Latest',
'dashboard.update.current': 'Current',
'dashboard.update.hasUpdate': 'New version available: {version}',
'dashboard.update.updating': 'Updating...',
'dashboard.update.success': 'Update successful',
@ -1332,6 +1337,7 @@ const translations = {
'providers.healthyProviders': 'Healthy Providers',
'providers.status.healthy': '{healthy}/{total} Available',
'providers.status.empty': '0/0 Nodes',
'providers.status.needsRefresh': 'Refreshing',
'providers.stat.totalAccounts': 'Total Accounts',
'providers.stat.healthyAccounts': 'Healthy Accounts',
'providers.stat.usageCount': 'Usage Count',

View file

@ -370,6 +370,7 @@ function renderProviderList(providers) {
const toggleButtonText = isDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled');
const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban';
const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning';
const needsRefresh = !!provider.needsRefresh;
// 构建错误信息显示
let errorInfoHtml = '';
@ -388,7 +389,10 @@ function renderProviderList(providers) {
<div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}">
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
<div class="provider-info">
<div class="provider-name">${provider.customName || provider.uuid}</div>
<div class="provider-name">
${provider.customName || provider.uuid}
${needsRefresh ? `<span class="badge badge-warning" style="font-size: 10px; margin-left: 8px; vertical-align: middle;"><i class="fas fa-sync-alt fa-spin"></i> <span data-i18n="providers.status.needsRefresh">${t('providers.status.needsRefresh')}</span></span>` : ''}
</div>
<div class="provider-meta">
<span class="health-status">
<i class="${healthIcon}"></i>

View file

@ -3108,6 +3108,8 @@ async function checkUpdate(silent = false) {
const updateBtn = document.getElementById('performUpdateBtn');
const updateBadge = document.getElementById('updateBadge');
const latestVersionText = document.getElementById('latestVersionText');
const versionSelectWrapper = document.getElementById('versionSelectWrapper');
const versionSelect = document.getElementById('versionSelect');
const checkBtnIcon = checkBtn?.querySelector('i');
const checkBtnText = checkBtn?.querySelector('span');
@ -3120,16 +3122,46 @@ async function checkUpdate(silent = false) {
const data = await window.apiClient.get('/check-update');
// 处理版本列表
if (versionSelect && data.availableVersions && data.availableVersions.length > 0) {
versionSelect.innerHTML = '';
data.availableVersions.forEach(version => {
const option = document.createElement('option');
option.value = version;
option.textContent = version;
// 如果是最新版本,增加标识
if (version === data.latestVersion) {
option.textContent += ` (${t('dashboard.update.latest') || 'Latest'})`;
}
// 如果是当前版本,增加标识
if (version === data.localVersion || version === `v${data.localVersion}`) {
option.textContent += ` (${t('dashboard.update.current') || 'Current'})`;
option.selected = true;
}
versionSelect.appendChild(option);
});
if (versionSelectWrapper) versionSelectWrapper.style.display = 'block';
if (updateBtn) {
updateBtn.style.display = 'inline-flex';
// 如果是回退,修改按钮文字
updateBtn.querySelector('span').textContent = t('dashboard.update.perform');
}
}
if (data.hasUpdate) {
if (updateBtn) updateBtn.style.display = 'inline-flex';
if (updateBadge) updateBadge.style.display = 'inline-flex';
if (latestVersionText) latestVersionText.textContent = data.latestVersion;
// 如果有新版本且未选择特定版本,默认选中最新
if (versionSelect && data.latestVersion) {
versionSelect.value = data.latestVersion;
}
if (!silent) {
showToast(t('common.info'), t('dashboard.update.hasUpdate', { version: data.latestVersion }), 'info');
}
} else {
if (updateBtn) updateBtn.style.display = 'none';
if (updateBadge) updateBadge.style.display = 'none';
if (!silent) {
showToast(t('common.info'), t('dashboard.update.upToDate'), 'success');
@ -3154,10 +3186,10 @@ async function checkUpdate(silent = false) {
*/
async function performUpdate() {
const updateBtn = document.getElementById('performUpdateBtn');
const latestVersionText = document.getElementById('latestVersionText');
const version = latestVersionText?.textContent || '';
const versionSelect = document.getElementById('versionSelect');
const selectedVersion = versionSelect?.value || '';
if (!confirm(t('dashboard.update.confirmMsg', { version }))) {
if (!confirm(t('dashboard.update.confirmMsg', { version: selectedVersion }))) {
return;
}
@ -3173,7 +3205,7 @@ async function performUpdate() {
showToast(t('common.info'), t('dashboard.update.updating'), 'info');
const data = await window.apiClient.post('/update');
const data = await window.apiClient.post('/update', { version: selectedVersion });
if (data.success) {
if (data.updated) {
@ -3183,8 +3215,8 @@ async function performUpdate() {
// 自动重启服务
await restartServiceAfterUpdate();
} else {
// 已是最新版本
showToast(t('common.info'), t('dashboard.update.upToDate'), 'info');
// 已是目标版本
showToast(t('common.info'), data.message || t('dashboard.update.upToDate'), 'info');
}
}
} catch (error) {

View file

@ -41,6 +41,11 @@
<div class="system-info-header">
<h3 data-i18n="dashboard.systemInfo">系统信息</h3>
<div class="update-controls">
<div id="versionSelectWrapper" style="display: none; margin-right: 8px;">
<select id="versionSelect" class="form-control select-sm" style="height: 32px; padding: 0 8px; font-size: 13px; min-width: 120px;">
<!-- Versions will be loaded here -->
</select>
</div>
<button id="checkUpdateBtn" class="btn btn-outline btn-sm" data-i18n-title="dashboard.update.checkTitle" title="检查更新">
<i class="fas fa-sync-alt"></i> <span data-i18n="dashboard.update.check">检查更新</span>
</button>

View file

@ -198,9 +198,14 @@
letter-spacing: 0.05em;
}
.provider-badge.official { background: var(--info-bg); color: var(--info-text); }
.provider-badge.oauth { background: var(--success-bg); color: var(--success-text); }
.provider-badge.responses { background: var(--warning-bg); color: var(--warning-text); }
.badge-warning {
background: var(--warning-bg);
color: var(--warning-text);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.routing-card-content {
padding: 1.5rem;