From 675ddb8282160f9e5b02cc293687ef612abb28be Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Sat, 30 May 2026 09:17:37 +0800 Subject: [PATCH] fix profile runtime status loading (#1142) --- packages/client/src/api/hermes/profiles.ts | 12 ++- .../src/components/layout/ProfileSelector.vue | 36 +++++-- packages/client/src/i18n/locales/de.ts | 1 + packages/client/src/i18n/locales/en.ts | 1 + packages/client/src/i18n/locales/es.ts | 1 + packages/client/src/i18n/locales/fr.ts | 1 + packages/client/src/i18n/locales/ja.ts | 1 + packages/client/src/i18n/locales/ko.ts | 1 + packages/client/src/i18n/locales/pt.ts | 1 + packages/client/src/i18n/locales/zh-TW.ts | 1 + packages/client/src/i18n/locales/zh.ts | 1 + .../server/src/controllers/hermes/profiles.ts | 93 +++++++++++++++++-- 12 files changed, 136 insertions(+), 14 deletions(-) diff --git a/packages/client/src/api/hermes/profiles.ts b/packages/client/src/api/hermes/profiles.ts index 852b0d5..87a6e6d 100644 --- a/packages/client/src/api/hermes/profiles.ts +++ b/packages/client/src/api/hermes/profiles.ts @@ -52,6 +52,11 @@ export interface ProfileRuntimeStatus { } } +export interface ProfileRuntimeStatusesResponse { + profiles: ProfileRuntimeStatus[] + refreshing?: boolean +} + export async function fetchProfiles(): Promise { const res = await request<{ profiles: HermesProfile[] }>('/api/hermes/profiles') return res.profiles @@ -66,8 +71,13 @@ export async function fetchProfileRuntimeStatus(name: string): Promise(`/api/hermes/profiles/${encodeURIComponent(name)}/runtime-status`) } +export async function fetchProfileRuntimeStatusesWithMeta(options: { refresh?: boolean } = {}): Promise { + const query = options.refresh === false ? '?refresh=0' : '' + return request(`/api/hermes/profiles/runtime-statuses${query}`) +} + export async function fetchProfileRuntimeStatuses(): Promise { - const res = await request<{ profiles: ProfileRuntimeStatus[] }>('/api/hermes/profiles/runtime-statuses') + const res = await fetchProfileRuntimeStatusesWithMeta() return res.profiles } diff --git a/packages/client/src/components/layout/ProfileSelector.vue b/packages/client/src/components/layout/ProfileSelector.vue index 616b4d4..76113fe 100644 --- a/packages/client/src/components/layout/ProfileSelector.vue +++ b/packages/client/src/components/layout/ProfileSelector.vue @@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue' import { NButton, NModal, NSpin, useMessage } from 'naive-ui' import { useProfilesStore } from '@/stores/hermes/profiles' import { - fetchProfileRuntimeStatuses, + fetchProfileRuntimeStatusesWithMeta, restartProfileGateway, restartProfileRuntime, type HermesProfile, @@ -31,21 +31,43 @@ const gatewayRestarting = ref>({}) const profileRestarting = ref>({}) const profileSwitching = ref>({}) const statusByProfile = computed(() => new Map(runtimeStatuses.value.map(status => [status.profile, status]))) +let runtimeRefreshToken = 0 -async function loadRuntimeStatuses() { - runtimeLoading.value = true +async function loadRuntimeStatuses(options: { background?: boolean } = {}): Promise { + const token = ++runtimeRefreshToken + if (!options.background) { + runtimeLoading.value = runtimeStatuses.value.length === 0 + } try { - runtimeStatuses.value = await fetchProfileRuntimeStatuses() + const res = await fetchProfileRuntimeStatusesWithMeta({ refresh: !options.background }) + if (token !== runtimeRefreshToken) return false + runtimeStatuses.value = res.profiles + return !!res.refreshing } catch { runtimeStatuses.value = [] + return false } finally { - runtimeLoading.value = false + if (token === runtimeRefreshToken) { + runtimeLoading.value = false + } } } function openProfileModal() { showProfileModal.value = true - void loadRuntimeStatuses() + void loadRuntimeStatuses().then((refreshing) => { + if (refreshing) scheduleRuntimeStatusPoll() + }) +} + +function scheduleRuntimeStatusPoll(attempt = 0) { + if (attempt >= 12 || typeof window === 'undefined') return + window.setTimeout(() => { + if (!showProfileModal.value) return + void loadRuntimeStatuses({ background: true }).then((refreshing) => { + if (refreshing) scheduleRuntimeStatusPoll(attempt + 1) + }) + }, attempt === 0 ? 700 : 1200) } function openAvatarModal(profile: HermesProfile) { @@ -116,10 +138,12 @@ async function handleAvatarFileChange(event: Event) { } function gatewayStatusText(running?: boolean) { + if (running == null) return t('profiles.runtime.checking') return running ? t('profiles.runtime.running') : t('profiles.runtime.stopped') } function bridgeStatusText(running?: boolean) { + if (running == null) return t('profiles.runtime.checking') return running ? t('profiles.runtime.active') : t('profiles.runtime.idle') } diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index fa5b2bd..a295804 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -706,6 +706,7 @@ jobTriggered: 'Job ausgelost', active: 'Aktiv', activeTag: 'Aktuell', idle: 'Leerlauf', + checking: 'Prüfung läuft', running: 'Läuft', stopped: 'Gestoppt', restartGateway: 'Gateway neu starten', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 9adcfa5..029a4f3 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -809,6 +809,7 @@ export default { active: 'Active', activeTag: 'Active', idle: 'Idle', + checking: 'Checking', running: 'Running', stopped: 'Stopped', restartGateway: 'Restart Gateway', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index 646e693..641fd8c 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -706,6 +706,7 @@ jobTriggered: 'Job ejecutado', active: 'Activo', activeTag: 'Actual', idle: 'Inactivo', + checking: 'Comprobando', running: 'En ejecución', stopped: 'Detenido', restartGateway: 'Reiniciar gateway', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index 07349c5..f488851 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -706,6 +706,7 @@ jobTriggered: 'Job declenche', active: 'Actif', activeTag: 'Actuel', idle: 'Inactif', + checking: 'Vérification', running: 'En cours', stopped: 'Arrêté', restartGateway: 'Redémarrer le gateway', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index 6b71ae6..f0d1903 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -706,6 +706,7 @@ export default { active: 'アクティブ', activeTag: '現在', idle: '待機中', + checking: '確認中', running: '実行中', stopped: '停止中', restartGateway: 'Gateway を再起動', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 1e2a44a..553bf4b 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -706,6 +706,7 @@ export default { active: '활성', activeTag: '현재', idle: '대기 중', + checking: '확인 중', running: '실행 중', stopped: '중지됨', restartGateway: 'Gateway 재시작', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index 1d432d8..f28ba26 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -706,6 +706,7 @@ jobTriggered: 'Job acionado', active: 'Ativo', activeTag: 'Atual', idle: 'Ocioso', + checking: 'Verificando', running: 'Em execução', stopped: 'Parado', restartGateway: 'Reiniciar gateway', diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index 44d1392..664cfde 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -801,6 +801,7 @@ export default { active: '使用中', activeTag: '目前', idle: '閒置', + checking: '檢測中', running: '執行中', stopped: '已停止', restartGateway: '重啟閘道', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index a9f371c..c5a01d3 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -801,6 +801,7 @@ export default { active: '活跃', activeTag: '当前', idle: '空闲', + checking: '检测中', running: '运行中', stopped: '已停止', restartGateway: '重启网关', diff --git a/packages/server/src/controllers/hermes/profiles.ts b/packages/server/src/controllers/hermes/profiles.ts index 8ad2eeb..1f23cd7 100644 --- a/packages/server/src/controllers/hermes/profiles.ts +++ b/packages/server/src/controllers/hermes/profiles.ts @@ -35,6 +35,17 @@ interface ProfileAvatarResponse { updatedAt?: number } +type RuntimeStatus = Awaited> + +interface RuntimeStatusCacheEntry { + status: RuntimeStatus + updatedAt: number +} + +const runtimeStatusCache = new Map() +let runtimeStatusRefreshPromise: Promise | null = null +let runtimeStatusMinimumFreshAt = 0 + const RESERVED_PROFILE_NAMES = new Set([ 'hermes', 'default', 'test', 'tmp', 'root', 'sudo', ]) @@ -309,6 +320,44 @@ async function buildRuntimeStatus(profile: HermesProfile | string, bridgeState?: } } +function setRuntimeStatusCache(status: RuntimeStatus, checkedAt = Date.now()): void { + runtimeStatusCache.set(status.profile, { + status, + updatedAt: checkedAt, + }) +} + +function listProfilesForStatusFast(): HermesProfile[] { + return filterVisibleProfiles(listProfilesFromDisk(getActiveProfileName())) +} + +async function refreshRuntimeStatusCache(checkedAt: number): Promise { + const profiles = await listProfilesForStatus() + const bridge = await readBridgeWorkers() + const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge))) + statuses.forEach(status => setRuntimeStatusCache(status, checkedAt)) +} + +function startRuntimeStatusRefresh(): void { + const startedAt = Date.now() + runtimeStatusRefreshPromise = refreshRuntimeStatusCache(startedAt) + .catch((err) => { + logger.warn(err, '[profiles] failed to refresh runtime status cache') + }) + .finally(() => { + runtimeStatusRefreshPromise = null + if (runtimeStatusMinimumFreshAt > startedAt) { + startRuntimeStatusRefresh() + } + }) +} + +function scheduleRuntimeStatusRefresh(): void { + runtimeStatusMinimumFreshAt = Math.max(runtimeStatusMinimumFreshAt, Date.now()) + if (runtimeStatusRefreshPromise) return + startRuntimeStatusRefresh() +} + export async function list(ctx: any) { try { let profiles: HermesProfile[] @@ -478,18 +527,35 @@ export async function runtimeStatus(ctx: any) { try { const profiles = await listProfilesForStatus() const profile = profiles.find(item => item.name === name) - ctx.body = await buildRuntimeStatus(profile || name) + const status = await buildRuntimeStatus(profile || name) + setRuntimeStatusCache(status) + ctx.body = status } catch { - ctx.body = await buildRuntimeStatus(name) + const status = await buildRuntimeStatus(name) + setRuntimeStatusCache(status) + ctx.body = status } } export async function runtimeStatuses(ctx: any) { try { - const profiles = filterProfilesForUser(ctx, await listProfilesForStatus()) - const bridge = await readBridgeWorkers() - const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge))) - ctx.body = { profiles: statuses } + const refreshParam = ctx.query?.refresh + const refreshRequested = refreshParam === undefined || (refreshParam !== '0' && refreshParam !== 'false') + if (refreshRequested) scheduleRuntimeStatusRefresh() + + const profiles = filterProfilesForUser(ctx, listProfilesForStatusFast()) + const statuses: RuntimeStatus[] = [] + profiles.forEach(profile => { + const cached = runtimeStatusCache.get(profile.name) + if (cached && cached.updatedAt >= runtimeStatusMinimumFreshAt) { + statuses.push(cached.status) + } + }) + + ctx.body = { + profiles: statuses, + refreshing: !!runtimeStatusRefreshPromise, + } } catch (err: any) { ctx.status = 500 ctx.body = { error: err.message } @@ -519,6 +585,17 @@ export async function restartGatewayForProfile(ctx: any) { try { const result = await bridgeCleanupClient().destroyProfile(name) logger.info('[profiles] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', name, result.destroyed) + const cached = runtimeStatusCache.get(name)?.status + if (cached) { + setRuntimeStatusCache({ + ...cached, + bridge: { + ...cached.bridge, + running: false, + }, + gateway, + }) + } } catch (err) { logger.warn(err, '[profiles] failed to destroy bridge sessions after gateway restart profile=%s', name) } @@ -542,10 +619,12 @@ export async function restartProfileRuntime(ctx: any) { logger.info('[profiles] destroyed bridge sessions after profile restart profile=%s destroyed=%s', name, result.destroyed) const profiles = await listProfilesForStatus() const profile = profiles.find(item => item.name === name) + const status = await buildRuntimeStatus(profile || name) + setRuntimeStatusCache(status) ctx.body = { success: true, destroyed: result.destroyed, - status: await buildRuntimeStatus(profile || name), + status, } } catch (err: any) { ctx.status = 500