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,29 @@
|
||||
import { request } from '../client'
|
||||
|
||||
export interface GatewayStatus {
|
||||
profile: string
|
||||
port: number
|
||||
host: string
|
||||
url: string
|
||||
running: boolean
|
||||
pid?: number
|
||||
}
|
||||
|
||||
export async function fetchGateways(): Promise<GatewayStatus[]> {
|
||||
const res = await request<{ gateways: GatewayStatus[] }>('/api/hermes/gateways')
|
||||
return res.gateways
|
||||
}
|
||||
|
||||
export async function startGateway(name: string): Promise<GatewayStatus> {
|
||||
const res = await request<{ success: boolean; gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/start`, { method: 'POST' })
|
||||
return res.gateway
|
||||
}
|
||||
|
||||
export async function stopGateway(name: string): Promise<void> {
|
||||
await request(`/api/hermes/gateways/${name}/stop`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function checkGatewayHealth(name: string): Promise<GatewayStatus> {
|
||||
const res = await request<{ gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/health`)
|
||||
return res.gateway
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, reactive } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { NButton, useMessage } from "naive-ui";
|
||||
@@ -21,6 +21,16 @@ const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const selectedKey = computed(() => route.name as string);
|
||||
|
||||
const collapsedGroups = reactive<Record<string, boolean>>({});
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
collapsedGroups[key] = !collapsedGroups[key];
|
||||
}
|
||||
|
||||
function isGroupCollapsed(key: string) {
|
||||
return !!collapsedGroups[key];
|
||||
}
|
||||
|
||||
function handleNav(key: string) {
|
||||
router.push({ name: key });
|
||||
}
|
||||
@@ -44,255 +54,154 @@ async function handleUpdate() {
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.chat' }"
|
||||
@click="handleNav('hermes.chat')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||
/>
|
||||
<!-- Chat (standalone) -->
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.chat' }" @click="handleNav('hermes.chat')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.chat") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.jobs' }"
|
||||
@click="handleNav('hermes.jobs')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.jobs") }}</span>
|
||||
</button>
|
||||
<!-- Agent -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" @click="toggleGroup('agent')">
|
||||
<span>{{ t("sidebar.groupAgent") }}</span>
|
||||
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('agent') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!isGroupCollapsed('agent')">
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.jobs' }" @click="handleNav('hermes.jobs')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.jobs") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.channels' }" @click="handleNav('hermes.channels')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.channels") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.skills' }" @click="handleNav('hermes.skills')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.skills") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.memory' }" @click="handleNav('hermes.memory')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.memory") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.models' }" @click="handleNav('hermes.models')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v4" />
|
||||
<path d="M12 19v4" />
|
||||
<path d="M1 12h4" />
|
||||
<path d="M19 12h4" />
|
||||
<path d="M4.22 4.22l2.83 2.83" />
|
||||
<path d="M16.95 16.95l2.83 2.83" />
|
||||
<path d="M4.22 19.78l2.83-2.83" />
|
||||
<path d="M16.95 7.05l2.83-2.83" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.models") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.models' }"
|
||||
@click="handleNav('hermes.models')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v4" />
|
||||
<path d="M12 19v4" />
|
||||
<path d="M1 12h4" />
|
||||
<path d="M19 12h4" />
|
||||
<path d="M4.22 4.22l2.83 2.83" />
|
||||
<path d="M16.95 16.95l2.83 2.83" />
|
||||
<path d="M4.22 19.78l2.83-2.83" />
|
||||
<path d="M16.95 7.05l2.83-2.83" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.models") }}</span>
|
||||
</button>
|
||||
<!-- Monitoring -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" @click="toggleGroup('monitoring')">
|
||||
<span>{{ t("sidebar.groupMonitoring") }}</span>
|
||||
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('monitoring') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!isGroupCollapsed('monitoring')">
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.logs' }" @click="handleNav('hermes.logs')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.logs") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.usage' }" @click="handleNav('hermes.usage')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.usage") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.channels' }"
|
||||
@click="handleNav('hermes.channels')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.channels") }}</span>
|
||||
</button>
|
||||
<!-- Tools -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" @click="toggleGroup('tools')">
|
||||
<span>{{ t("sidebar.groupTools") }}</span>
|
||||
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('tools') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!isGroupCollapsed('tools')">
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.terminal' }" @click="handleNav('hermes.terminal')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.terminal") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.skills' }"
|
||||
@click="handleNav('hermes.skills')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.skills") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.memory' }"
|
||||
@click="handleNav('hermes.memory')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.memory") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.logs' }"
|
||||
@click="handleNav('hermes.logs')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.logs") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.usage' }"
|
||||
@click="handleNav('hermes.usage')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.usage") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.profiles' }"
|
||||
@click="handleNav('hermes.profiles')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.profiles") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.terminal' }"
|
||||
@click="handleNav('hermes.terminal')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.terminal") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.settings' }"
|
||||
@click="handleNav('hermes.settings')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t("sidebar.settings") }}</span>
|
||||
</button>
|
||||
<!-- System -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" @click="toggleGroup('system')">
|
||||
<span>{{ t("sidebar.groupSystem") }}</span>
|
||||
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('system') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!isGroupCollapsed('system')">
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.gateways' }" @click="handleNav('hermes.gateways')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
|
||||
<line x1="6" y1="6" x2="6.01" y2="6" />
|
||||
<line x1="6" y1="18" x2="6.01" y2="18" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.gateways") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.profiles") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.settings' }" @click="handleNav('hermes.settings')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.settings") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<ProfileSelector />
|
||||
@@ -394,7 +303,7 @@ async function handleUpdate() {
|
||||
display: flex;
|
||||
padding-top: 12px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
scrollbar-width: none;
|
||||
@@ -404,6 +313,51 @@ async function handleUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
&.nav-group-bottom {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 8px 12px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: $radius-sm;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.nav-group:first-child & {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-group-arrow {
|
||||
transition: transform $transition-fast;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { NSelect, useMessage } from 'naive-ui'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const profilesStore = useProfilesStore()
|
||||
|
||||
const options = computed(() =>
|
||||
@@ -20,6 +21,7 @@ function handleChange(value: string | number | Array<string | number>) {
|
||||
if (typeof value === 'string' && value !== activeName.value) {
|
||||
profilesStore.switchProfile(value).then(ok => {
|
||||
if (ok) {
|
||||
message.success(t('profiles.switchSuccess', { name: value }))
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,6 +35,8 @@ export default {
|
||||
confirm: 'Confirm',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
@@ -48,7 +50,14 @@ export default {
|
||||
logs: 'Logs',
|
||||
usage: 'Usage',
|
||||
channels: 'Channels',
|
||||
gateways: 'Gateways',
|
||||
terminal: 'Terminal',
|
||||
groupConversation: 'Conversation',
|
||||
groupPlatform: 'Platform',
|
||||
groupAgent: 'Agent',
|
||||
groupSystem: 'System',
|
||||
groupMonitoring: 'Monitoring',
|
||||
groupTools: 'Tools',
|
||||
settings: 'Settings',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
@@ -65,6 +74,8 @@ export default {
|
||||
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
|
||||
attachFiles: 'Attach files',
|
||||
stop: 'Stop',
|
||||
start: 'Start',
|
||||
stopGateway: 'Stop Gateway',
|
||||
send: 'Send',
|
||||
contextUsed: 'Context used:',
|
||||
sessions: 'Sessions',
|
||||
@@ -218,6 +229,14 @@ export default {
|
||||
},
|
||||
|
||||
// Profiles
|
||||
gateways: {
|
||||
title: 'Gateways',
|
||||
running: 'Running',
|
||||
stopped: 'Stopped',
|
||||
started: 'Started',
|
||||
startFailed: 'Failed to start gateway',
|
||||
stopFailed: 'Failed to stop gateway',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Profiles',
|
||||
create: 'Create Profile',
|
||||
|
||||
@@ -35,6 +35,8 @@ export default {
|
||||
confirm: '确定',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
start: '启动',
|
||||
stop: '停止',
|
||||
},
|
||||
|
||||
// 侧边栏
|
||||
@@ -48,7 +50,14 @@ export default {
|
||||
logs: '日志',
|
||||
usage: '用量',
|
||||
channels: '频道',
|
||||
gateways: '网关',
|
||||
terminal: '终端',
|
||||
groupConversation: '对话',
|
||||
groupPlatform: '平台',
|
||||
groupAgent: '代理',
|
||||
groupSystem: '系统',
|
||||
groupMonitoring: '监控',
|
||||
groupTools: '工具',
|
||||
settings: '设置',
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
@@ -65,6 +74,8 @@ export default {
|
||||
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
|
||||
attachFiles: '添加附件',
|
||||
stop: '停止',
|
||||
start: '启动',
|
||||
stopGateway: '停止网关',
|
||||
send: '发送',
|
||||
contextUsed: '上下文已用:',
|
||||
sessions: '会话',
|
||||
@@ -417,6 +428,16 @@ export default {
|
||||
qrScanedHint: '已扫描,请在手机上确认...',
|
||||
},
|
||||
|
||||
// 网关
|
||||
gateways: {
|
||||
title: '网关',
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
started: '已启动',
|
||||
startFailed: '启动失败',
|
||||
stopFailed: '停止失败',
|
||||
},
|
||||
|
||||
// 语言
|
||||
language: {
|
||||
label: '语言',
|
||||
|
||||
@@ -55,6 +55,11 @@ const router = createRouter({
|
||||
name: 'hermes.settings',
|
||||
component: () => import('@/views/hermes/SettingsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/gateways',
|
||||
name: 'hermes.gateways',
|
||||
component: () => import('@/views/hermes/GatewaysView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/channels',
|
||||
name: 'hermes.channels',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { fetchGateways, startGateway, stopGateway, type GatewayStatus } from '@/api/hermes/gateways'
|
||||
|
||||
export const useGatewayStore = defineStore('gateways', () => {
|
||||
const gateways = ref<GatewayStatus[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchStatus() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchGateways()
|
||||
gateways.value = Array.isArray(data) ? data : Object.values(data || {})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function start(name: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const status = await startGateway(name)
|
||||
// Update the specific gateway in the list
|
||||
const idx = gateways.value.findIndex(g => g.profile === name)
|
||||
if (idx >= 0) {
|
||||
gateways.value[idx] = status
|
||||
} else {
|
||||
gateways.value.push(status)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(name: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
await stopGateway(name)
|
||||
// Update the specific gateway in the list
|
||||
const gw = gateways.value.find(g => g.profile === name)
|
||||
if (gw) {
|
||||
gw.running = false
|
||||
gw.pid = undefined
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { gateways, loading, fetchStatus, start, stop }
|
||||
})
|
||||
@@ -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