diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 99d2512..ab87f0a 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -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})`); } diff --git a/src/services/service-manager.js b/src/services/service-manager.js index ac25461..e574b54 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -563,7 +563,8 @@ export async function getProviderStatus(config, options = {}) { 'customName', 'isHealthy', 'lastErrorTime', - 'lastErrorMessage' + 'lastErrorMessage', + 'needsRefresh' ]; // identify 字段映射表 const identifyFieldMap = { diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 8a986d2..d6692ad 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -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] = []; + } } }); } diff --git a/src/ui-modules/update-api.js b/src/ui-modules/update-api.js index ac8976d..b112dc1 100644 --- a/src/ui-modules/update-api.js +++ b/src/ui-modules/update-api.js @@ -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} 最新版本号或 null + * 通过 GitHub API 获取最近的版本列表 + * @param {number} limit - 限制返回的版本数量 + * @returns {Promise} 版本列表 */ -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} 最新版本号或 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} 更新结果 */ -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; } -} \ No newline at end of file +} diff --git a/static/app/i18n.js b/static/app/i18n.js index ced6336..e51e1ad 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -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', diff --git a/static/app/modal.js b/static/app/modal.js index 60a1cd3..16132eb 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -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) {
-
${provider.customName || provider.uuid}
+
+ ${provider.customName || provider.uuid} + ${needsRefresh ? ` ${t('providers.status.needsRefresh')}` : ''} +
diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 50ab6d5..02087ac 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -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) { diff --git a/static/components/section-dashboard.html b/static/components/section-dashboard.html index 83a1b93..67c2253 100644 --- a/static/components/section-dashboard.html +++ b/static/components/section-dashboard.html @@ -41,6 +41,11 @@

系统信息

+ diff --git a/static/components/section-providers.css b/static/components/section-providers.css index f8304de..f30523b 100644 --- a/static/components/section-providers.css +++ b/static/components/section-providers.css @@ -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;