fix(desktop,dashboard): served profile's api_server/webhook read connected with their /p/<profile>/ URL; shared-gateway restart asks first
Under gateway.multiplex_profiles a secondary's api_server and webhook are never built as
adapters (run_adapters skips SHARED_LISTENER_MIRROR_PLATFORMS: the default's listener answers
/p/<profile>/...). The multiplexer record therefore has no `<profile>:api_server` entry,
profile_platforms_from_multiplexer() returned {} for them and both /api/messaging/platforms
and /api/status?profile= fell through to `pending_restart`: the Desktop Messaging card and
Command Center said "Restart needed" forever for a platform that was answering.
- gateway.status.shared_listener_mirror_platforms projects the default's LIVE api_server /
webhook entry onto every served secondary with `ingress_url` = `<listener>/p/<profile>/v1`
(`.../webhooks/<route>`); a dead default listener is not mirrored. The api_server / webhook
adapters stamp the listener they actually bound (`listener_base`) on connect so the URL is
the real one, not a config guess. `hermes status` lists those URLs beside the other
shared-ingress platforms.
- /api/status?profile= reports `gateway_shared_with` (every profile the multiplexer carries)
when the served rung answered; null for a standalone gateway.
- Desktop: the messaging card shows the URL line; "Restart gateway" from a served profile
(statusbar menu, Cmd+K, messaging/webhooks banners, Command Center) confirms "Restart the
shared gateway? All bots on this device reconnect: default, alpha, beta" (Restart all /
Cancel) and toasts "Shared gateway restarted (3 bots)". Standalone keeps the silent path.
- Dashboard: same confirm + toast on the System page and the sidebar restart; the 409 from
start/stop on a served profile renders as an inline notice instead of a raw error toast.
This commit is contained in:
@@ -30,7 +30,9 @@ import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $sessions, sessionPinId } from '@/store/session'
|
||||
import { confirmSharedGatewayRestart } from '@/store/system-actions'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -267,6 +269,13 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
async (kind: 'restart' | 'update') => {
|
||||
setSystemError('')
|
||||
|
||||
// A profile served by the shared multiplexer restarts every bot on this device: ask first.
|
||||
const shared = kind === 'restart' ? await confirmSharedGatewayRestart() : null
|
||||
|
||||
if (shared === false) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const started = kind === 'restart' ? await restartGateway() : await updateHermes()
|
||||
let nextStatus: ActionStatusResponse | null = null
|
||||
@@ -283,6 +292,10 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
}
|
||||
}
|
||||
|
||||
if (shared && nextStatus && !nextStatus.running && (nextStatus.exit_code ?? 0) === 0) {
|
||||
notify({ kind: 'success', message: cc.sharedGatewayRestarted(shared.length) })
|
||||
}
|
||||
|
||||
if (!nextStatus) {
|
||||
const pendingStatus = {
|
||||
exit_code: null,
|
||||
|
||||
@@ -1024,6 +1024,19 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
// A served secondary's api_server/webhook live on the shared gateway listener under
|
||||
// /p/<profile>/: the state pill says connected, this line says where to point the client.
|
||||
if (platform.ingress_url) {
|
||||
return (
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground break-all">
|
||||
{t.messaging.sharedListenerUrl}{' '}
|
||||
<code className="font-mono text-foreground" data-slot="ingress-url">
|
||||
{platform.ingress_url}
|
||||
</code>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (!platform.enabled || platform.state === 'connected') {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1319,6 +1319,10 @@ export const ar = defineLocale({
|
||||
restartGateway: 'إعادة تشغيل البوابة',
|
||||
openBrowser: 'فتح المتصفح',
|
||||
gatewayRestartFailed: 'فشل إعادة تشغيل البوابة.',
|
||||
sharedGatewayRestartTitle: 'إعادة تشغيل البوابة المشتركة؟',
|
||||
sharedGatewayRestartDescription: bots => `تتم إعادة اتصال جميع البوتات على هذا الجهاز: ${bots}`,
|
||||
sharedGatewayRestartConfirm: 'إعادة تشغيل الكل',
|
||||
sharedGatewayRestarted: count => `تمت إعادة تشغيل البوابة المشتركة (${count} بوت)`,
|
||||
updateHermes: 'تحديث Hermes',
|
||||
reloadWindow: 'إعادة تحميل النافذة',
|
||||
actionRunning: 'الإجراء قيد التشغيل',
|
||||
@@ -1364,6 +1368,7 @@ export const ar = defineLocale({
|
||||
},
|
||||
unknown: 'غير معروف',
|
||||
hintPendingRestart: 'تحتاج إعادة تشغيل لتطبيق التغييرات.',
|
||||
sharedListenerUrl: 'يُخدم عبر مستمع البوابة المشتركة على',
|
||||
hintGatewayStopped: 'البوابة متوقفة.',
|
||||
restartNeeded: 'تم الحفظ. أعد تشغيل بوابة المراسلة لتطبيق الإعدادات الجديدة.',
|
||||
restartNow: 'إعادة التشغيل الآن',
|
||||
|
||||
@@ -1918,6 +1918,10 @@ export const en: Translations = {
|
||||
restartGateway: 'Restart gateway',
|
||||
openBrowser: 'Open browser',
|
||||
gatewayRestartFailed: 'Gateway restart failed.',
|
||||
sharedGatewayRestartTitle: 'Restart the shared gateway?',
|
||||
sharedGatewayRestartDescription: bots => `All bots on this device reconnect: ${bots}`,
|
||||
sharedGatewayRestartConfirm: 'Restart all',
|
||||
sharedGatewayRestarted: count => `Shared gateway restarted (${count} ${count === 1 ? 'bot' : 'bots'})`,
|
||||
updateHermes: 'Update Hermes',
|
||||
reloadWindow: 'Reload window',
|
||||
actionRunning: 'running',
|
||||
@@ -2011,6 +2015,7 @@ export const en: Translations = {
|
||||
},
|
||||
unknown: 'Unknown',
|
||||
hintPendingRestart: 'Restart the gateway from the status bar to apply this change.',
|
||||
sharedListenerUrl: 'Served on the shared gateway listener at',
|
||||
hintGatewayStopped: 'Start the gateway from the status bar to connect.',
|
||||
credentialsSet: 'Credentials set',
|
||||
needsSetup: 'Needs setup',
|
||||
|
||||
@@ -1637,6 +1637,10 @@ export const ja = defineLocale({
|
||||
restartGateway: 'ゲートウェイを再起動',
|
||||
openBrowser: 'ブラウザを開く',
|
||||
gatewayRestartFailed: 'ゲートウェイの再起動に失敗しました。',
|
||||
sharedGatewayRestartTitle: '共有ゲートウェイを再起動しますか?',
|
||||
sharedGatewayRestartDescription: bots => `このデバイス上のすべてのボットが再接続します: ${bots}`,
|
||||
sharedGatewayRestartConfirm: 'すべて再起動',
|
||||
sharedGatewayRestarted: count => `共有ゲートウェイを再起動しました(${count} ボット)`,
|
||||
updateHermes: 'Hermes を更新',
|
||||
reloadWindow: 'ウィンドウを再読み込み',
|
||||
actionRunning: '実行中',
|
||||
@@ -1683,6 +1687,7 @@ export const ja = defineLocale({
|
||||
},
|
||||
unknown: '不明',
|
||||
hintPendingRestart: 'この変更を適用するにはステータスバーからゲートウェイを再起動してください。',
|
||||
sharedListenerUrl: '共有ゲートウェイのリスナーで提供中:',
|
||||
hintGatewayStopped: 'ステータスバーからゲートウェイを起動して接続してください。',
|
||||
restartNeeded: '保存しました。新しい設定を反映するにはメッセージングゲートウェイを再起動してください。',
|
||||
restartNow: '今すぐ再起動',
|
||||
|
||||
@@ -1808,6 +1808,10 @@ export const ru = defineLocale({
|
||||
restartGateway: 'Перезапустить шлюз',
|
||||
openBrowser: 'Открыть браузер',
|
||||
gatewayRestartFailed: 'Не удалось перезапустить шлюз.',
|
||||
sharedGatewayRestartTitle: 'Перезапустить общий шлюз?',
|
||||
sharedGatewayRestartDescription: bots => `Все боты на этом устройстве переподключатся: ${bots}`,
|
||||
sharedGatewayRestartConfirm: 'Перезапустить все',
|
||||
sharedGatewayRestarted: count => `Общий шлюз перезапущен (ботов: ${count})`,
|
||||
updateHermes: 'Обновить Hermes',
|
||||
reloadWindow: 'Перезагрузить окно',
|
||||
actionRunning: 'выполняется',
|
||||
@@ -1900,6 +1904,7 @@ export const ru = defineLocale({
|
||||
},
|
||||
unknown: 'Неизвестно',
|
||||
hintPendingRestart: 'Перезапустите шлюз из строки состояния, чтобы применить это изменение.',
|
||||
sharedListenerUrl: 'Обслуживается общим слушателем шлюза по адресу',
|
||||
hintGatewayStopped: 'Запустите шлюз из строки состояния для подключения.',
|
||||
credentialsSet: 'Учётные данные заданы',
|
||||
needsSetup: 'Нужна настройка',
|
||||
|
||||
@@ -1694,6 +1694,10 @@ export interface Translations {
|
||||
restartGateway: string
|
||||
openBrowser: string
|
||||
gatewayRestartFailed: string
|
||||
sharedGatewayRestartTitle: string
|
||||
sharedGatewayRestartDescription: (bots: string) => string
|
||||
sharedGatewayRestartConfirm: string
|
||||
sharedGatewayRestarted: (count: number) => string
|
||||
updateHermes: string
|
||||
reloadWindow: string
|
||||
actionRunning: string
|
||||
@@ -1777,6 +1781,7 @@ export interface Translations {
|
||||
states: Record<string, string>
|
||||
unknown: string
|
||||
hintPendingRestart: string
|
||||
sharedListenerUrl: string
|
||||
hintGatewayStopped: string
|
||||
credentialsSet: string
|
||||
needsSetup: string
|
||||
|
||||
@@ -1579,6 +1579,10 @@ export const zhHant = defineLocale({
|
||||
restartGateway: '重新啟動閘道',
|
||||
openBrowser: '開啟瀏覽器',
|
||||
gatewayRestartFailed: '閘道重新啟動失敗。',
|
||||
sharedGatewayRestartTitle: '重新啟動共享閘道?',
|
||||
sharedGatewayRestartDescription: bots => `此裝置上的所有機器人都會重新連線:${bots}`,
|
||||
sharedGatewayRestartConfirm: '全部重新啟動',
|
||||
sharedGatewayRestarted: count => `共享閘道已重新啟動(${count} 個機器人)`,
|
||||
updateHermes: '更新 Hermes',
|
||||
reloadWindow: '重新載入視窗',
|
||||
actionRunning: '執行中',
|
||||
@@ -1625,6 +1629,7 @@ export const zhHant = defineLocale({
|
||||
},
|
||||
unknown: '未知',
|
||||
hintPendingRestart: '在狀態列重新啟動閘道以套用此變更。',
|
||||
sharedListenerUrl: '透過共享閘道監聽器提供,位址為',
|
||||
hintGatewayStopped: '在狀態列啟動閘道以建立連線。',
|
||||
restartNeeded: '已儲存。請重新啟動訊息閘道以套用新設定。',
|
||||
restartNow: '立即重新啟動',
|
||||
|
||||
@@ -2084,6 +2084,10 @@ export const zh = defineLocale({
|
||||
restartGateway: '重启网关',
|
||||
openBrowser: '打开浏览器',
|
||||
gatewayRestartFailed: '网关重启失败。',
|
||||
sharedGatewayRestartTitle: '重启共享网关?',
|
||||
sharedGatewayRestartDescription: bots => `此设备上的所有机器人都会重新连接:${bots}`,
|
||||
sharedGatewayRestartConfirm: '全部重启',
|
||||
sharedGatewayRestarted: count => `共享网关已重启(${count} 个机器人)`,
|
||||
updateHermes: '更新 Hermes',
|
||||
reloadWindow: '重新载入窗口',
|
||||
actionRunning: '运行中',
|
||||
@@ -2177,6 +2181,7 @@ export const zh = defineLocale({
|
||||
},
|
||||
unknown: '未知',
|
||||
hintPendingRestart: '在状态栏重启网关以应用此更改。',
|
||||
sharedListenerUrl: '通过共享网关监听器提供,地址为',
|
||||
hintGatewayStopped: '在状态栏启动网关以建立连接。',
|
||||
credentialsSet: '凭据已设置',
|
||||
needsSetup: '需要设置',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { sharedGatewayProfiles } from './shared-gateway-restart'
|
||||
|
||||
describe('sharedGatewayProfiles', () => {
|
||||
it('lists every bot the shared multiplexer carries, default first, when the profile is served', () => {
|
||||
expect(sharedGatewayProfiles({ gateway_shared_with: ['beta', 'alpha', 'default'] })).toEqual([
|
||||
'default',
|
||||
'alpha',
|
||||
'beta'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the plain restart for standalone gateways and older backends', () => {
|
||||
// Standalone profile: the backend answers null.
|
||||
expect(sharedGatewayProfiles({ gateway_shared_with: null })).toBeNull()
|
||||
// Older backend: the key does not exist at all.
|
||||
expect(sharedGatewayProfiles({})).toBeNull()
|
||||
expect(sharedGatewayProfiles(null)).toBeNull()
|
||||
// A multiplexer carrying nobody else restarts only itself: no "all bots" copy.
|
||||
expect(sharedGatewayProfiles({ gateway_shared_with: ['default'] })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
/** Profiles a gateway restart would blip when the polled profile is carried by the shared
|
||||
* multiplexer, in display order (default first, then as recorded). `null` for a standalone
|
||||
* gateway or an older backend that does not report `gateway_shared_with` — those keep the
|
||||
* plain no-dialog restart. A record naming only one profile is not shared either. */
|
||||
export function sharedGatewayProfiles(status: Pick<StatusResponse, 'gateway_shared_with'> | null | undefined): null | string[] {
|
||||
const shared = status?.gateway_shared_with
|
||||
|
||||
if (!Array.isArray(shared)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const names = [...new Set(shared.map(name => String(name).trim()).filter(Boolean))]
|
||||
|
||||
if (names.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
return names.sort((left, right) => (left === 'default' ? -1 : right === 'default' ? 1 : left.localeCompare(right)))
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { getActionStatus, restartGateway } from '@/hermes'
|
||||
import { getActionStatus, getStatus, restartGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { sharedGatewayProfiles } from '@/lib/shared-gateway-restart'
|
||||
import { confirm } from '@/store/confirm'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ActionResponse } from '@/types/hermes'
|
||||
|
||||
const POLL_ATTEMPTS = 18
|
||||
@@ -34,6 +36,37 @@ async function awaitAction(name: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Under `gateway.multiplex_profiles` the profile in view has no gateway of its
|
||||
// own: "Restart gateway" restarts the ONE shared multiplexer and every bot on
|
||||
// this device blips. Ask first, naming them; standalone gateways (and older
|
||||
// backends that do not report `gateway_shared_with`) keep the silent restart.
|
||||
// Resolves the served list when the user confirmed, `null` when nothing is
|
||||
// shared, `false` when they cancelled.
|
||||
export async function confirmSharedGatewayRestart(): Promise<false | null | string[]> {
|
||||
let shared: null | string[] = null
|
||||
|
||||
try {
|
||||
shared = sharedGatewayProfiles(await getStatus())
|
||||
} catch {
|
||||
// Status unavailable: fall back to the plain restart rather than blocking it.
|
||||
return null
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ok = await confirm({
|
||||
title: translateNow('commandCenter.sharedGatewayRestartTitle'),
|
||||
description: translateNow('commandCenter.sharedGatewayRestartDescription', shared.join(', ')),
|
||||
confirmLabel: translateNow('commandCenter.sharedGatewayRestartConfirm'),
|
||||
cancelLabel: translateNow('common.cancel'),
|
||||
destructive: true
|
||||
})
|
||||
|
||||
return ok ? shared : false
|
||||
}
|
||||
|
||||
// Restart the messaging gateway, surfacing progress in the statusbar gateway
|
||||
// indicator. Self-contained and never rejects, so every trigger — Cmd+K, the
|
||||
// messaging save/toggle toasts — gets identical feedback from a plain
|
||||
@@ -41,12 +74,22 @@ async function awaitAction(name: string): Promise<void> {
|
||||
// Resolves `true` when the restart child completed cleanly (callers that keep
|
||||
// a "restart needed" banner clear it on that signal only).
|
||||
export async function runGatewayRestart(): Promise<boolean> {
|
||||
const shared = await confirmSharedGatewayRestart()
|
||||
|
||||
if (shared === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
$gatewayRestarting.set(true)
|
||||
|
||||
try {
|
||||
const started: ActionResponse = await restartGateway()
|
||||
await awaitAction(started.name)
|
||||
|
||||
if (shared) {
|
||||
notify({ kind: 'success', message: translateNow('commandCenter.sharedGatewayRestarted', shared.length) })
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, translateNow('commandCenter.gatewayRestartFailed'))
|
||||
|
||||
@@ -280,6 +280,9 @@ export interface MessagingPlatformInfo {
|
||||
gateway_running: boolean
|
||||
home_channel?: MessagingHomeChannel | null
|
||||
id: string
|
||||
/** Served secondary under a multiplexed gateway: the /p/<profile>/ URL on the shared listener
|
||||
* the client (or vendor console) must call. Null for standalone and default-profile platforms. */
|
||||
ingress_url?: null | string
|
||||
name: string
|
||||
state?: null | string
|
||||
updated_at?: null | string
|
||||
@@ -1332,6 +1335,9 @@ export interface StatusResponse {
|
||||
gateway_pid: number | null
|
||||
gateway_platforms: Record<string, PlatformStatus>
|
||||
gateway_running: boolean
|
||||
/** Every profile the gateway process serves when the polled profile is carried by the shared
|
||||
* multiplexer (e.g. ['default', 'alpha', 'beta']); null/absent for a standalone gateway. */
|
||||
gateway_shared_with?: string[] | null
|
||||
gateway_state: string | null
|
||||
gateway_updated_at: string | null
|
||||
hermes_home: string
|
||||
|
||||
@@ -249,6 +249,8 @@ PORT_BINDING_CONDITIONAL_MODES: dict[str, str] = {"feishu": "webhook"}
|
||||
# Port-binders whose /p/<profile>/ surface is a MIRROR served by the default's own adapter; a secondary
|
||||
# never gets an instance of these (api_server: /p/<profile>/v1/..., webhook: profile-bound routes).
|
||||
SHARED_LISTENER_MIRROR_PLATFORMS = frozenset({"api_server", "webhook"})
|
||||
# Path a client appends to ``<default listener>/p/<profile>`` to reach each mirror.
|
||||
SHARED_LISTENER_MIRROR_PATHS: dict[str, str] = {"api_server": "/v1", "webhook": "/webhooks/<route>"}
|
||||
|
||||
|
||||
def platform_binds_port(platform_value: str, extra: Optional[dict] = None) -> bool:
|
||||
|
||||
@@ -3976,7 +3976,8 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"config.yaml: platforms.api_server.port",
|
||||
self.name, self._host, self._port, exc)
|
||||
return False
|
||||
self._mark_connected()
|
||||
from gateway.platforms.shared_ingress import listener_base_url
|
||||
self._mark_connected(listener_base=listener_base_url(self._host, self._port))
|
||||
logger.info(
|
||||
"[%s] API server listening on http://%s:%d (model: %s)",
|
||||
self.name, self._host, self._port, self._model_name)
|
||||
|
||||
@@ -2042,14 +2042,19 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def _mark_connected(self) -> None:
|
||||
def _mark_connected(self, *, listener_base: Optional[str] = None) -> None:
|
||||
"""``listener_base`` (``http://host:port``) is stamped by port-binders after a REAL bind: under the
|
||||
multiplexer it is the shared listener a served profile's ``/p/<profile>/`` mirror hangs off, and
|
||||
what the dashboard/Desktop report as that profile's api_server/webhook URL."""
|
||||
self._running = True
|
||||
self._fatal_error_code = self._fatal_error_message = None
|
||||
self._fatal_error_retryable = True
|
||||
if self.send_path_degraded:
|
||||
self._mark_degraded()
|
||||
else:
|
||||
self._write_runtime_status_safe("connected", platform_state="connected", error_code=None, error_message=None)
|
||||
extra = {"listener_base": listener_base} if listener_base else {}
|
||||
self._write_runtime_status_safe(
|
||||
"connected", platform_state="connected", error_code=None, error_message=None, **extra)
|
||||
|
||||
def _mark_degraded(self) -> None:
|
||||
"""Publish ``retrying`` for a running adapter whose delivery path is unproven."""
|
||||
|
||||
@@ -29,6 +29,14 @@ def shared_ingress_profile(adapter: Any) -> Optional[str]:
|
||||
return getattr(adapter, "_shared_listener_profile", None) or None
|
||||
|
||||
|
||||
def listener_base_url(host: Any, port: Any) -> str:
|
||||
"""``http://host:port`` clients use to reach a listener bound on ``host`` (wildcards → loopback)."""
|
||||
host = "127.0.0.1" if host is None or str(host).strip() in _WILDCARD_HOSTS else str(host)
|
||||
if ":" in host and not host.startswith("["):
|
||||
host = f"[{host}]"
|
||||
return f"http://{host}:{port or 0}"
|
||||
|
||||
|
||||
def shared_listener_base(runner: Any) -> Optional[str]:
|
||||
"""``http://host:port`` of the default profile's live listener (api_server first, then webhook)."""
|
||||
from gateway.config import Platform
|
||||
@@ -37,11 +45,7 @@ def shared_listener_base(runner: Any) -> Optional[str]:
|
||||
adapter = adapters.get(platform)
|
||||
if adapter is None:
|
||||
continue
|
||||
host = getattr(adapter, "_host", None)
|
||||
host = "127.0.0.1" if host is None or str(host).strip() in _WILDCARD_HOSTS else str(host)
|
||||
if ":" in host and not host.startswith("["):
|
||||
host = f"[{host}]"
|
||||
return f"http://{host}:{getattr(adapter, '_port', 0)}"
|
||||
return listener_base_url(getattr(adapter, "_host", None), getattr(adapter, "_port", 0))
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -235,7 +235,8 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
logger.error("[webhook] Could not bind %s:%d: %s. Set a different host or port in config.yaml under "
|
||||
"platforms.webhook.extra.", self._host or "all IPv4+IPv6 interfaces", self._port, exc)
|
||||
return False
|
||||
self._mark_connected()
|
||||
from gateway.platforms.shared_ingress import listener_base_url
|
||||
self._mark_connected(listener_base=listener_base_url(self._host, self._port))
|
||||
logger.info("[webhook] Listening on %s:%d — routes: %s", self._host or "* (all interfaces, IPv4+IPv6)",
|
||||
self._port, ", ".join(self._routes.keys()) or "(none configured)")
|
||||
self._wire_plugin_handlers(None)
|
||||
|
||||
+35
-4
@@ -804,7 +804,7 @@ def write_runtime_status(
|
||||
active_agents: Any = _UNSET, platform: Any = _UNSET, platform_state: Any = _UNSET,
|
||||
error_code: Any = _UNSET, error_message: Any = _UNSET, needs_attention: Any = _UNSET,
|
||||
retrying_since: Any = _UNSET, served_profiles: Any = _UNSET, session_store: Any = _UNSET,
|
||||
ingress_url: Any = _UNSET, clear_profile_platforms: bool = False,
|
||||
ingress_url: Any = _UNSET, listener_base: Any = _UNSET, clear_profile_platforms: bool = False,
|
||||
drop_profile_platforms: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Persist gateway runtime health information for diagnostics/status. ``drop_profile_platforms``
|
||||
@@ -848,6 +848,9 @@ def write_runtime_status(
|
||||
("retrying_since", retrying_since, None),
|
||||
# Shared-listener secondaries: the /p/<profile>/ callback URL the vendor console must target.
|
||||
("ingress_url", ingress_url, None),
|
||||
# Bound listener (``http://host:port``) of the default's api_server/webhook: a served
|
||||
# profile's mirror of that platform is reported off it (``<listener_base>/p/<profile>/...``).
|
||||
("listener_base", listener_base, None),
|
||||
))
|
||||
# Per-entry writer provenance: top-level pid/start_time only identify the most recent
|
||||
# writer; /api/status tells "live" from "preserved" by exact (pid, start_time) equality.
|
||||
@@ -945,15 +948,43 @@ def multiplexer_liveness_for_profile(profile_dir: Path) -> Optional[tuple[int, d
|
||||
return pid, read_runtime_status(get_default_hermes_root() / "gateway_state.json") or {}
|
||||
|
||||
|
||||
def shared_listener_mirror_platforms(runtime: Optional[dict[str, Any]], profile: str) -> dict[str, Any]:
|
||||
"""Entries for the api_server/webhook mirrors a served ``profile`` gets from the DEFAULT's
|
||||
listener. The multiplexer never builds those adapters for a secondary (``gateway.run_adapters``
|
||||
skips them: ``SHARED_LISTENER_MIRROR_PLATFORMS``), so the record has no ``<profile>:api_server``
|
||||
entry and every reader fell through to ``pending_restart`` — "Restart needed" forever while
|
||||
``/p/<profile>/v1/...`` answered. Only a live default entry is mirrored; its state is the profile's
|
||||
state, plus the ``/p/<profile>`` URL the client must actually call.
|
||||
"""
|
||||
from gateway.config import SHARED_LISTENER_MIRROR_PATHS, SHARED_LISTENER_MIRROR_PLATFORMS
|
||||
plats = (runtime or {}).get("platforms")
|
||||
if not profile or profile == "default" or not isinstance(plats, dict):
|
||||
return {}
|
||||
mirrored: dict[str, Any] = {}
|
||||
for name in sorted(SHARED_LISTENER_MIRROR_PLATFORMS):
|
||||
entry = plats.get(name)
|
||||
if not isinstance(entry, dict) or entry.get("state") not in {"connected", "connecting", "retrying"}:
|
||||
continue
|
||||
# api_server and webhook bind separate ports; each mirror hangs off its own listener. A record
|
||||
# from an older gateway carries no ``listener_base``: connected, URL unknown.
|
||||
base = entry.get("listener_base")
|
||||
url = f"{base}/p/{profile}{SHARED_LISTENER_MIRROR_PATHS.get(name, '')}" if isinstance(base, str) and base else None
|
||||
mirrored[name] = {k: v for k, v in entry.items() if k != "listener_base"}
|
||||
mirrored[name].update(ingress_url=url, mirrored_from="default")
|
||||
return mirrored
|
||||
|
||||
|
||||
def profile_platforms_from_multiplexer(runtime: Optional[dict[str, Any]], profile: str) -> dict[str, Any]:
|
||||
"""The ``<profile>:<platform>`` entries of a multiplexer record, re-keyed to bare platform names — the
|
||||
same shape a standalone gateway for ``profile`` writes into its own ``gateway_state.json``."""
|
||||
same shape a standalone gateway for ``profile`` writes into its own ``gateway_state.json`` — plus the
|
||||
default listener's api_server/webhook mirrors the profile is served through (``ingress_url`` set)."""
|
||||
plats = (runtime or {}).get("platforms")
|
||||
if not isinstance(plats, dict):
|
||||
return {}
|
||||
prefix = f"{profile}:"
|
||||
return {key[len(prefix):]: value for key, value in plats.items()
|
||||
if isinstance(key, str) and key.startswith(prefix) and isinstance(value, dict)}
|
||||
own = {key[len(prefix):]: value for key, value in plats.items()
|
||||
if isinstance(key, str) and key.startswith(prefix) and isinstance(value, dict)}
|
||||
return {**shared_listener_mirror_platforms(runtime, profile), **own}
|
||||
|
||||
|
||||
def resolve_gateway_liveness(
|
||||
|
||||
@@ -48,7 +48,7 @@ def served_profile_ingress_urls(profile: Optional[str] = None) -> dict[str, dict
|
||||
serves on its shared listener (``<profile>:<platform>`` entries carrying ``ingress_url``). This is
|
||||
what the user pastes into the vendor console (Twilio, LINE, Teams, ...). ``profile`` narrows the map."""
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from gateway.status import read_runtime_status
|
||||
from gateway.status import read_runtime_status, shared_listener_mirror_platforms
|
||||
if live_default_gateway_pid() is None:
|
||||
return {}
|
||||
runtime = read_runtime_status(get_default_hermes_root() / "gateway_state.json") or {}
|
||||
@@ -66,6 +66,12 @@ def served_profile_ingress_urls(profile: Optional[str] = None) -> dict[str, dict
|
||||
if profile and name != profile:
|
||||
continue
|
||||
urls.setdefault(name, {})[platform] = str(url)
|
||||
# api_server/webhook are the default's adapters mirrored at /p/<profile>/ (no entry of their own).
|
||||
served = [str(p) for p in (runtime.get("served_profiles") or []) if p and p != "default"]
|
||||
for name in served if not profile else [p for p in served if p == profile]:
|
||||
for platform, entry in shared_listener_mirror_platforms(runtime, name).items():
|
||||
if entry.get("ingress_url"):
|
||||
urls.setdefault(name, {})[platform] = str(entry["ingress_url"])
|
||||
return urls
|
||||
|
||||
|
||||
|
||||
@@ -285,10 +285,14 @@ async def _resolve_gateway_status(profile_dir: Optional[Path], health_url) -> Di
|
||||
if gateway_running and gateway_state is None and remote_health_body is not None:
|
||||
gateway_state = "running"
|
||||
|
||||
# ``liveness.runtime`` is set only when the shared multiplexer answered for a served profile: its
|
||||
# gateway IS that process, so name every bot a restart would blip ("default, alpha, beta").
|
||||
served = (liveness.runtime or {}).get("served_profiles")
|
||||
return {
|
||||
"runtime": runtime, "gateway_running": gateway_running, "gateway_pid": liveness.pid,
|
||||
"gateway_state": gateway_state, "gateway_platforms": gateway_platforms,
|
||||
"gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at}
|
||||
"gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at,
|
||||
"gateway_shared_with": [str(p) for p in served] if isinstance(served, list) else None}
|
||||
|
||||
|
||||
def _auth_gate_status() -> Dict[str, Any]:
|
||||
@@ -434,6 +438,8 @@ async def get_status(profile: Optional[str] = None):
|
||||
"gateway_platforms": gateway["gateway_platforms"],
|
||||
"gateway_exit_reason": gateway["gateway_exit_reason"],
|
||||
"gateway_updated_at": gateway["gateway_updated_at"],
|
||||
# Non-null only for a profile served by the shared multiplexer: every profile that process carries.
|
||||
"gateway_shared_with": gateway["gateway_shared_with"],
|
||||
"active_agents": active_agents,
|
||||
"gateway_busy": derive_gateway_busy(
|
||||
gateway_running=gateway_running, gateway_state=gateway_state,
|
||||
|
||||
@@ -108,7 +108,10 @@ def test_dashboard_liveness_ladder_reports_served_profile_running(served_root):
|
||||
coder = served_root / "profiles" / "coder"
|
||||
live = resolve_gateway_liveness(profile_dir=coder, health_probe=None, use_cache=False)
|
||||
assert live.running is True and live.pid == os.getpid() and live.source == "multiplexer"
|
||||
assert profile_platforms_from_multiplexer(live.runtime, "coder") == {"telegram": {"state": "connected"}}
|
||||
plats = profile_platforms_from_multiplexer(live.runtime, "coder")
|
||||
assert plats["telegram"] == {"state": "connected"}
|
||||
# The default's api_server is coder's too (served at /p/coder/), not a missing adapter.
|
||||
assert plats["api_server"]["state"] == "connected"
|
||||
# An unserved profile keeps the historical "stopped" answer.
|
||||
other = resolve_gateway_liveness(profile_dir=served_root / "profiles" / "other", health_probe=None, use_cache=False)
|
||||
assert other.running is False
|
||||
|
||||
@@ -43,7 +43,8 @@ def test_unscoped_liveness_in_a_served_profile_process_matches_the_scoped_answer
|
||||
scoped = resolve_gateway_liveness(profile_dir=alpha, health_probe=None, use_cache=False)
|
||||
unscoped = resolve_gateway_liveness(health_probe=None, use_cache=False)
|
||||
assert (unscoped.running, unscoped.pid, unscoped.source) == (scoped.running, scoped.pid, "multiplexer")
|
||||
assert profile_platforms_from_multiplexer(unscoped.runtime, "alpha") == {"telegram": {"state": "connected"}}
|
||||
plats = profile_platforms_from_multiplexer(unscoped.runtime, "alpha")
|
||||
assert plats["telegram"] == {"state": "connected"} and plats["api_server"]["state"] == "connected"
|
||||
|
||||
|
||||
def test_unscoped_lifecycle_verbs_in_a_served_profile_process_address_the_multiplexer(pooled_served_process):
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""A served secondary's api_server/webhook are MIRRORS of the default's listener (``/p/<profile>/...``),
|
||||
never adapters of their own, so the multiplexer record has no ``<profile>:api_server`` entry. Every
|
||||
status reader used to fall through to ``pending_restart``: the Desktop Messaging card and Command
|
||||
Center read "Restart needed" forever for a platform that was answering. The mirror must project as the
|
||||
default's live state plus the URL the client has to call; ``/api/status?profile=`` names the profiles a
|
||||
restart of the shared gateway would blip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def served_root(tmp_path, monkeypatch):
|
||||
root = tmp_path / "hermes"
|
||||
(root / "profiles" / "alpha").mkdir(parents=True)
|
||||
(root / "config.yaml").write_text("gateway: {multiplex_profiles: true}\n")
|
||||
(root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(root)}))
|
||||
(root / "gateway_state.json").write_text(json.dumps({
|
||||
"pid": os.getpid(), "hermes_home": str(root), "gateway_state": "running",
|
||||
"served_profiles": ["default", "alpha", "beta"],
|
||||
"platforms": {
|
||||
"api_server": {"state": "connected", "listener_base": "http://127.0.0.1:45719"},
|
||||
"webhook": {"state": "fatal", "error_code": "port_in_use"},
|
||||
"alpha:telegram": {"state": "connected"},
|
||||
}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
|
||||
import hermes_constants
|
||||
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
|
||||
return root
|
||||
|
||||
|
||||
def test_served_profile_projects_the_default_listener_mirrors_with_their_url(served_root):
|
||||
from gateway.status import profile_platforms_from_multiplexer, resolve_gateway_liveness
|
||||
alpha = served_root / "profiles" / "alpha"
|
||||
live = resolve_gateway_liveness(profile_dir=alpha, health_probe=None, use_cache=False)
|
||||
plats = profile_platforms_from_multiplexer(live.runtime, "alpha")
|
||||
# The mirror inherits the default's live state and points at the profile's own prefix.
|
||||
assert plats["api_server"]["state"] == "connected"
|
||||
assert plats["api_server"]["ingress_url"] == "http://127.0.0.1:45719/p/alpha/v1"
|
||||
# A dead default listener is dead for the profile too — never "connected" by fiat.
|
||||
assert "webhook" not in plats
|
||||
assert plats["telegram"] == {"state": "connected"}
|
||||
# The default profile keeps its own un-prefixed entries; nothing is mirrored onto it.
|
||||
assert "ingress_url" not in profile_platforms_from_multiplexer(live.runtime, "default").get("api_server", {})
|
||||
|
||||
|
||||
def test_messaging_card_for_a_served_profile_reads_connected_not_restart_needed(served_root, monkeypatch):
|
||||
from hermes_cli.web_routers import messaging
|
||||
monkeypatch.setattr(messaging, "_platform_enablement", lambda *a, **k: (True, True, None))
|
||||
entry = {"id": "api_server", "name": "API server", "description": "", "docs_url": "", "env_vars": [],
|
||||
"required_env": []}
|
||||
alpha = served_root / "profiles" / "alpha"
|
||||
[payload] = messaging._platform_payloads(alpha, [entry])
|
||||
assert payload["gateway_running"] is True
|
||||
assert payload["state"] == "connected", payload
|
||||
assert payload["ingress_url"] == "http://127.0.0.1:45719/p/alpha/v1"
|
||||
+11
-4
@@ -105,6 +105,7 @@ import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
import { latchChatActivation } from "@/lib/chat-activation";
|
||||
import { sharedGatewayProfiles, sharedGatewayRestartDescription } from "@/lib/shared-gateway";
|
||||
import { api } from "@/lib/api";
|
||||
import type { StatusResponse, UpdateCheckResponse } from "@/lib/api";
|
||||
|
||||
@@ -943,6 +944,8 @@ function SidebarSystemActions({
|
||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||
useSystemActions();
|
||||
const canUpdateHermes = status?.can_update_hermes === true;
|
||||
// Served by the shared multiplexer: a restart blips every bot on this device — say which.
|
||||
const sharedGateway = sharedGatewayProfiles(status);
|
||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||
const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false);
|
||||
const [updateConfirmInfo, setUpdateConfirmInfo] =
|
||||
@@ -1076,17 +1079,21 @@ function SidebarSystemActions({
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={t.status.restartGateway}
|
||||
confirmLabel={sharedGateway ? "Restart all" : t.status.restartGateway}
|
||||
description={
|
||||
t.status.restartGatewayConfirmMessage ??
|
||||
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward."
|
||||
sharedGateway
|
||||
? sharedGatewayRestartDescription(sharedGateway)
|
||||
: (t.status.restartGatewayConfirmMessage ??
|
||||
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward.")
|
||||
}
|
||||
loading={pendingAction === "restart"}
|
||||
onCancel={() => setRestartConfirmOpen(false)}
|
||||
onConfirm={confirmRestart}
|
||||
open={restartConfirmOpen}
|
||||
title={
|
||||
t.status.restartGatewayConfirmTitle ?? `${t.status.restartGateway}?`
|
||||
sharedGateway
|
||||
? "Restart the shared gateway?"
|
||||
: (t.status.restartGatewayConfirmTitle ?? `${t.status.restartGateway}?`)
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ActionStatusResponse } from "@/lib/api";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { sharedGatewayProfiles, sharedGatewayRestartedMessage } from "@/lib/shared-gateway";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
SystemActionsContext,
|
||||
@@ -44,10 +45,18 @@ export function SystemActionsProvider({
|
||||
setActionStatus(resp);
|
||||
if (!resp.running) {
|
||||
const ok = resp.exit_code === 0;
|
||||
// A restart of the shared multiplexer reconnected every bot on the device: name the count.
|
||||
const shared =
|
||||
ok && activeAction === "restart"
|
||||
? sharedGatewayProfiles(await api.getStatus().catch(() => null))
|
||||
: null;
|
||||
if (cancelled) return;
|
||||
setToast({
|
||||
type: ok ? "success" : "error",
|
||||
message: ok
|
||||
? t.status.actionFinished
|
||||
? shared
|
||||
? sharedGatewayRestartedMessage(shared.length)
|
||||
: t.status.actionFinished
|
||||
: `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`,
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1912,6 +1912,10 @@ export interface StatusResponse {
|
||||
gateway_pid: number | null;
|
||||
gateway_platforms: Record<string, PlatformStatus>;
|
||||
gateway_running: boolean;
|
||||
/** Every profile the gateway process serves when the managed profile is carried by the
|
||||
* shared multiplexer (e.g. ["default", "alpha", "beta"]); null/absent for a standalone
|
||||
* gateway or an older backend. */
|
||||
gateway_shared_with?: string[] | null;
|
||||
gateway_state: string | null;
|
||||
gateway_updated_at: string | null;
|
||||
hermes_home: string;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
servedProfileRefusal,
|
||||
sharedGatewayProfiles,
|
||||
} from "./shared-gateway";
|
||||
|
||||
describe("sharedGatewayProfiles", () => {
|
||||
it("names every bot on the shared multiplexer, default first", () => {
|
||||
expect(
|
||||
sharedGatewayProfiles({ gateway_shared_with: ["beta", "default", "alpha"] }),
|
||||
).toEqual(["default", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("is null for standalone gateways, older backends and a lone default", () => {
|
||||
expect(sharedGatewayProfiles({ gateway_shared_with: null })).toBeNull();
|
||||
expect(sharedGatewayProfiles({})).toBeNull();
|
||||
expect(sharedGatewayProfiles({ gateway_shared_with: ["default"] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("servedProfileRefusal", () => {
|
||||
it("unwraps the 409 detail into a sentence and ignores other failures", () => {
|
||||
const err = new Error(
|
||||
'409: {"detail":"The default gateway already serves profile \'alpha\' as a multiplexer; stop it from the default profile instead of a separate gateway for this profile."}',
|
||||
);
|
||||
expect(servedProfileRefusal(err)).toMatch(/^The default gateway already serves profile 'alpha'/);
|
||||
expect(servedProfileRefusal(new Error("500: boom"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
|
||||
/** Profiles a gateway restart would blip when the managed profile is carried by the shared
|
||||
* multiplexer (default first). `null` for a standalone gateway or an older backend without
|
||||
* `gateway_shared_with`; those keep the plain restart copy. */
|
||||
export function sharedGatewayProfiles(
|
||||
status: Pick<StatusResponse, "gateway_shared_with"> | null | undefined,
|
||||
): string[] | null {
|
||||
const shared = status?.gateway_shared_with;
|
||||
if (!Array.isArray(shared)) return null;
|
||||
const names = [...new Set(shared.map((n) => String(n).trim()).filter(Boolean))];
|
||||
if (names.length < 2) return null;
|
||||
return names.sort((a, b) =>
|
||||
a === "default" ? -1 : b === "default" ? 1 : a.localeCompare(b),
|
||||
);
|
||||
}
|
||||
|
||||
export function sharedGatewayRestartDescription(profiles: string[]): string {
|
||||
return `All bots on this device reconnect: ${profiles.join(", ")}`;
|
||||
}
|
||||
|
||||
export function sharedGatewayRestartedMessage(count: number): string {
|
||||
return `Shared gateway restarted (${count} ${count === 1 ? "bot" : "bots"})`;
|
||||
}
|
||||
|
||||
/** The REST layer throws `"<status>: <body>"`; a 409 on gateway start/stop for a served profile
|
||||
* carries the multiplexer explanation in `detail`. Return it as a plain sentence, else null. */
|
||||
export function servedProfileRefusal(error: unknown): string | null {
|
||||
const text = error instanceof Error ? error.message : String(error ?? "");
|
||||
if (!text.startsWith("409")) return null;
|
||||
const detail = text.match(/"detail"\s*:\s*"([^"]+)"/)?.[1];
|
||||
return detail ?? text.replace(/^409:\s*/, "");
|
||||
}
|
||||
@@ -46,6 +46,12 @@ import { HermesConsoleModal } from "@/components/HermesConsoleModal";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import {
|
||||
servedProfileRefusal,
|
||||
sharedGatewayProfiles,
|
||||
sharedGatewayRestartDescription,
|
||||
sharedGatewayRestartedMessage,
|
||||
} from "@/lib/shared-gateway";
|
||||
import type {
|
||||
StatusResponse,
|
||||
MemoryStatus,
|
||||
@@ -290,7 +296,14 @@ export default function SystemPage() {
|
||||
}, [loadAll]);
|
||||
|
||||
// ── Gateway lifecycle ──────────────────────────────────────────────
|
||||
// A profile served by the shared multiplexer has no gateway of its own: Restart restarts
|
||||
// the ONE process every bot on this device runs in, so confirm first and say so after;
|
||||
// Start/Stop answer 409 with an explanation that belongs in a notice, not a raw error.
|
||||
const sharedGateway = sharedGatewayProfiles(status);
|
||||
const [sharedRestartOpen, setSharedRestartOpen] = useState(false);
|
||||
const [servedNotice, setServedNotice] = useState<string | null>(null);
|
||||
const runGateway = async (verb: "start" | "stop" | "restart") => {
|
||||
setServedNotice(null);
|
||||
try {
|
||||
if (verb === "start") {
|
||||
await api.startGateway();
|
||||
@@ -305,9 +318,21 @@ export default function SystemPage() {
|
||||
showToast(`Gateway ${verb} started`, "success");
|
||||
setTimeout(loadAll, 3000);
|
||||
} catch (e) {
|
||||
const refusal = servedProfileRefusal(e);
|
||||
if (refusal) {
|
||||
setServedNotice(refusal);
|
||||
return;
|
||||
}
|
||||
showToast(`Gateway ${verb} failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
const requestRestart = () => {
|
||||
if (sharedGateway) {
|
||||
setSharedRestartOpen(true);
|
||||
return;
|
||||
}
|
||||
void runGateway("restart");
|
||||
};
|
||||
|
||||
const migrateToMultiplex = async () => {
|
||||
try {
|
||||
@@ -420,6 +445,9 @@ export default function SystemPage() {
|
||||
|
||||
const handleActionComplete = useCallback(
|
||||
(action: string, exitCode: number | null) => {
|
||||
if (action === "gateway-restart" && exitCode === 0 && sharedGateway) {
|
||||
showToast(sharedGatewayRestartedMessage(sharedGateway.length), "success");
|
||||
}
|
||||
if (action === "backup" && pendingBackupArchive) {
|
||||
if (exitCode === 0) {
|
||||
setDownloadableBackupArchive(pendingBackupArchive);
|
||||
@@ -429,7 +457,7 @@ export default function SystemPage() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[pendingBackupArchive, showToast],
|
||||
[pendingBackupArchive, sharedGateway, showToast],
|
||||
);
|
||||
|
||||
const downloadBackup = async () => {
|
||||
@@ -673,6 +701,18 @@ export default function SystemPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={sharedRestartOpen}
|
||||
onCancel={() => setSharedRestartOpen(false)}
|
||||
onConfirm={() => {
|
||||
setSharedRestartOpen(false);
|
||||
void runGateway("restart");
|
||||
}}
|
||||
title="Restart the shared gateway?"
|
||||
description={sharedGatewayRestartDescription(sharedGateway ?? [])}
|
||||
confirmLabel="Restart all"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={canUpdateHermes && updateConfirmOpen}
|
||||
onCancel={() => setUpdateConfirmOpen(false)}
|
||||
@@ -1079,7 +1119,7 @@ export default function SystemPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={() => runGateway("restart")}
|
||||
onClick={requestRestart}
|
||||
prefix={<RotateCw className="h-3.5 w-3.5" />}
|
||||
>
|
||||
Restart
|
||||
@@ -1096,6 +1136,11 @@ export default function SystemPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
{(sharedGateway || servedNotice) && (
|
||||
<CardContent className="border-t border-current/10 py-3 text-xs text-muted-foreground" data-slot="shared-gateway-notice">
|
||||
{servedNotice ?? `Served by the shared gateway with ${sharedGateway!.join(", ")}.`}
|
||||
</CardContent>
|
||||
)}
|
||||
{migratePlan && !migratePlan.already_multiplexed && migratePlan.profiles.length > 1 && (
|
||||
migratePlan.eligible || migratePlan.blockers.length > 0
|
||||
) && (
|
||||
|
||||
@@ -130,9 +130,15 @@ loop. `hermes -p coder gateway stop` refuses the same way (exit 78) when coder h
|
||||
gateway of its own — there is nothing to stop but the multiplexer, which
|
||||
`hermes gateway stop` on the default profile takes down for every served profile.
|
||||
The dashboard and Desktop app follow the CLI: for a served profile the "Start" and
|
||||
"Stop" gateway actions answer `409` with the same explanation, and "Restart"
|
||||
restarts the multiplexer (the process that actually serves the profile) instead of
|
||||
spawning a `-p coder gateway restart` that could only fail.
|
||||
"Stop" gateway actions answer `409` with the same explanation (rendered as an inline
|
||||
notice on the System page), and "Restart" restarts the multiplexer (the process that
|
||||
actually serves the profile) instead of spawning a `-p coder gateway restart` that
|
||||
could only fail. Because that restart reconnects every bot on the device, both apps
|
||||
first ask *"Restart the shared gateway? All bots on this device reconnect: default,
|
||||
coder, research"* (the list is the running gateway's `served_profiles`) and report
|
||||
*"Shared gateway restarted (3 bots)"* when it completes. A standalone profile keeps
|
||||
the plain restart. `/api/status?profile=coder` carries the same list as
|
||||
`gateway_shared_with` (null for a standalone gateway).
|
||||
"Served" is read from the running gateway's own record (`served_profiles` in the
|
||||
default home's `gateway_state.json`), so it stays correct when the multiplexer was
|
||||
enabled only through `GATEWAY_MULTIPLEX_PROFILES` in the default profile's
|
||||
@@ -253,8 +259,13 @@ Inbound callback URLs on the shared listener:
|
||||
```
|
||||
|
||||
`hermes gateway status` and `hermes status` on the default profile list the same
|
||||
URLs per served profile, and the dashboard's Channels page shows them as each
|
||||
platform's `ingress_url` when viewing that profile. A per-profile
|
||||
URLs per served profile, and the dashboard's Channels page and the Desktop
|
||||
Messaging page show them as each platform's `ingress_url` when viewing that
|
||||
profile. The default's own `api_server` and `webhook` are reported the same way
|
||||
for a served profile — as **connected** with `ingress_url`
|
||||
`http://127.0.0.1:8642/p/coder/v1` (respectively `.../p/coder/webhooks/<route>`) —
|
||||
since the profile has no adapter of its own for them; it is the default's listener
|
||||
answering under the `/p/coder/` prefix. A per-profile
|
||||
`SMS_WEBHOOK_PORT`, `LINE_PORT`, `TEAMS_PORT`, … in a secondary's `.env` is
|
||||
ignored under the multiplexer (nothing binds); it applies again the moment that
|
||||
profile runs its own standalone gateway.
|
||||
|
||||
Reference in New Issue
Block a user