feat: add multi-gateway management with auto port detection
- Add GatewayManager for multi-profile gateway lifecycle management - Auto-detect running gateways on startup via PID + health check - Port conflict detection: check managed gateways, allocated ports, and system-level port availability (TCP bind test) - Two-phase startup: sequential port resolution, parallel process launch - Use `gateway start/restart` on normal systems, `gateway run --replace` on WSL/Docker - Wait for health check before returning start/stop responses - Add Gateways page with card-based layout showing profile status - Reorganize sidebar navigation into collapsible groups - Hide API server settings (now auto-managed by GatewayManager) - Profile switch reloads page; Ctrl+C no longer stops gateways - Remove redundant ensureApiServerConfig from index.ts and profiles.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { NSpin, NButton, NTag, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGatewayStore } from '@/stores/hermes/gateways'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const gatewayStore = useGatewayStore()
|
||||
|
||||
onMounted(() => {
|
||||
gatewayStore.fetchStatus()
|
||||
})
|
||||
|
||||
async function handleToggle(name: string, running: boolean) {
|
||||
try {
|
||||
if (running) {
|
||||
await gatewayStore.stop(name)
|
||||
message.success(`${t('gateways.stopped')}: ${name}`)
|
||||
} else {
|
||||
await gatewayStore.start(name)
|
||||
message.success(`${t('gateways.started')}: ${name}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gateways-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('gateways.title') }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="gateways-content">
|
||||
<NSpin :show="gatewayStore.loading" size="large">
|
||||
<div v-if="gatewayStore.gateways.length === 0" class="empty-state">
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="gateway-list">
|
||||
<div v-for="gw in gatewayStore.gateways" :key="gw.profile" class="gateway-card">
|
||||
<div class="gateway-info">
|
||||
<div class="gateway-name">{{ gw.profile }}</div>
|
||||
<div class="gateway-meta">
|
||||
<span class="meta-item">{{ gw.host }}:{{ gw.port }}</span>
|
||||
<span v-if="gw.pid" class="meta-item">PID: {{ gw.pid }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gateway-actions">
|
||||
<NTag :type="gw.running ? 'success' : 'default'" size="small" round>
|
||||
{{ gw.running ? t('gateways.running') : t('gateways.stopped') }}
|
||||
</NTag>
|
||||
<NButton
|
||||
size="small"
|
||||
:type="gw.running ? 'warning' : 'default'"
|
||||
:color="gw.running ? undefined : '#18181b'"
|
||||
round
|
||||
@click="handleToggle(gw.profile, gw.running)"
|
||||
>
|
||||
{{ gw.running ? t('common.stop') : t('common.start') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.gateways-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gateways-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.gateway-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gateway-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.gateway-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gateway-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.gateway-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -63,72 +63,6 @@ async function saveApiServer(values: Record<string, any>) {
|
||||
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
|
||||
<PrivacySettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="api_server" :tab="t('settings.tabs.apiServer')">
|
||||
<section class="settings-section">
|
||||
<SettingRow
|
||||
:label="t('settings.apiServer.enable')"
|
||||
:hint="t('settings.apiServer.enableHint')"
|
||||
>
|
||||
<NSwitch
|
||||
:value="settingsStore.platforms?.api_server?.enabled"
|
||||
@update:value="(v) => saveApiServer({ enabled: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
:label="t('settings.apiServer.host')"
|
||||
:hint="t('settings.apiServer.hostHint')"
|
||||
>
|
||||
<NInput
|
||||
:default-value="settingsStore.platforms?.api_server?.host || ''"
|
||||
size="small"
|
||||
class="input-md"
|
||||
@change="(v: string) => saveApiServer({ host: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
:label="t('settings.apiServer.port')"
|
||||
:hint="t('settings.apiServer.portHint')"
|
||||
>
|
||||
<NInputNumber
|
||||
:default-value="settingsStore.platforms?.api_server?.port"
|
||||
:min="1024"
|
||||
:max="65535"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@blur="(e: FocusEvent) => {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (val) saveApiServer({ port: Number(val) })
|
||||
}"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
:label="t('settings.apiServer.key')"
|
||||
:hint="t('settings.apiServer.keyHint')"
|
||||
>
|
||||
<NInput
|
||||
:default-value="settingsStore.platforms?.api_server?.key || ''"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
size="small"
|
||||
class="input-md"
|
||||
@change="(v: string) => saveApiServer({ key: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
:label="t('settings.apiServer.cors')"
|
||||
:hint="t('settings.apiServer.corsHint')"
|
||||
>
|
||||
<NInput
|
||||
:default-value="
|
||||
settingsStore.platforms?.api_server?.cors_origins || ''
|
||||
"
|
||||
size="small"
|
||||
class="input-md"
|
||||
@change="(v: string) => saveApiServer({ cors_origins: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user