Files
map-client-vue/web/src/components/MCPSettings.vue
2025-10-14 14:18:20 +08:00

1091 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="mcp-settings-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<h1>MCP 设置</h1>
<p>Model Context Protocol 服务器配置和管理</p>
</div>
<div class="header-stats">
<div class="stat-item">
<span class="stat-value">{{ servers.length }}</span>
<span class="stat-label">总计</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ connectedServers.length }}</span>
<span class="stat-label">已连接</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ availableTools.length }}</span>
<span class="stat-label">可用工具</span>
</div>
</div>
</div>
<!-- 快速操作 -->
<div class="quick-actions">
<n-button type="primary" @click="showAddModal = true">
<template #icon>
<n-icon :component="PlusIcon" />
</template>
添加服务器
</n-button>
<n-button @click="refreshAllServers" :loading="refreshing">
<template #icon>
<n-icon :component="RefreshIcon" />
</template>
刷新全部
</n-button>
<n-button @click="testAllConnections" :loading="testing">
<template #icon>
<n-icon :component="TestIcon" />
</template>
测试连接
</n-button>
<n-button @click="checkAllStatus" :loading="checking">
<template #icon>
<n-icon :component="CheckIcon" />
</template>
检查状态
</n-button>
</div>
<!-- 服务器列表 -->
<div class="servers-section">
<div class="section-header">
<h2>已配置的服务器</h2>
<div class="section-controls">
<n-input
v-model:value="searchText"
placeholder="搜索服务器..."
clearable
style="width: 200px"
>
<template #prefix>
<n-icon :component="SearchIcon" />
</template>
</n-input>
<n-select
v-model:value="filterStatus"
:options="statusFilters"
placeholder="状态筛选"
clearable
style="width: 150px"
/>
</div>
</div>
<div class="servers-grid">
<div
v-for="server in filteredServers"
:key="server.id"
class="server-card"
:class="{
'connected': server.status === 'connected',
'error': server.status === 'error',
'connecting': server.status === 'connecting'
}"
>
<!-- 服务器头部信息 -->
<div class="server-header">
<div class="server-info">
<div class="server-avatar">
<n-icon :component="getServerIcon(server.type)" size="20" />
</div>
<div class="server-details">
<h3>{{ server.name }}</h3>
<p>{{ server.url }}</p>
</div>
</div>
<div class="server-status">
<n-tag
:type="getStatusType(server.status)"
size="small"
:bordered="false"
>
{{ getStatusText(server.status) }}
</n-tag>
</div>
</div>
<!-- 服务器描述 -->
<div class="server-description" v-if="server.description">
<p>{{ server.description }}</p>
</div>
<!-- 服务器能力 -->
<div class="server-capabilities" v-if="server.status === 'connected' && server.capabilities">
<div class="capabilities-grid">
<div class="capability-item">
<n-icon :component="ToolIcon" />
<span>{{ server.capabilities.tools?.length || 0 }} 工具</span>
</div>
<div class="capability-item">
<n-icon :component="FileIcon" />
<span>{{ server.capabilities.resources?.length || 0 }} 资源</span>
</div>
<div class="capability-item">
<n-icon :component="TemplateIcon" />
<span>{{ server.capabilities.prompts?.length || 0 }} 提示</span>
</div>
</div>
</div>
<!-- 工具列表 -->
<div class="server-tools" v-if="server.status === 'connected' && server.capabilities?.tools?.length">
<div class="tools-header">
<span>可用工具</span>
<n-button text size="tiny" @click="toggleToolsList(server.id)">
{{ expandedTools.includes(server.id) ? '收起' : '展开' }}
</n-button>
</div>
<div v-if="expandedTools.includes(server.id)" class="tools-list">
<div
v-for="tool in server.capabilities.tools.slice(0, 5)"
:key="tool.name"
class="tool-item"
@click="executeToolModal(server.id, tool)"
>
<span class="tool-name">{{ tool.name }}</span>
<span class="tool-desc">{{ tool.description || '无描述' }}</span>
</div>
<div v-if="server.capabilities.tools.length > 5" class="tools-more">
+{{ server.capabilities.tools.length - 5 }} 更多工具
</div>
</div>
</div>
<!-- 服务器操作 -->
<div class="server-actions">
<n-button
v-if="server.status !== 'connected' && server.status !== 'connecting'"
type="primary"
size="small"
@click="connectServer(server.id)"
:loading="false"
>
连接
</n-button>
<n-button
v-else-if="server.status === 'connected'"
size="small"
@click="disconnectServer(server.id)"
>
断开
</n-button>
<n-button size="small" @click="openServerDetail(server)">
<template #icon>
<n-icon :component="EditIcon" />
</template>
编辑
</n-button>
<n-button size="small" @click="testServer(server.id)" :loading="server.id === testingServerId">
<template #icon>
<n-icon :component="TestIcon" />
</template>
</n-button>
<n-dropdown :options="getServerMenuOptions(server)" @select="handleServerAction">
<n-button size="small">
<template #icon>
<n-icon :component="DotsIcon" />
</template>
</n-button>
</n-dropdown>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredServers.length === 0" class="empty-state">
<div class="empty-content">
<n-icon :component="ServerIcon" size="48" />
<h3>{{ servers.length === 0 ? '暂无服务器' : '无匹配结果' }}</h3>
<p>{{ servers.length === 0 ? '点击添加按钮配置您的第一个MCP服务器' : '尝试调整搜索条件' }}</p>
<!-- 示例服务器快速添加 -->
<div v-if="servers.length === 0" class="quick-start">
<n-button type="primary" @click="showAddModal = true">
添加服务器
</n-button>
<n-button @click="addExampleServer" ghost>
添加示例服务器
</n-button>
</div>
</div>
</div>
</div>
</div>
<!-- MCP协议信息 -->
<div class="protocol-info">
<n-card title="关于 Model Context Protocol">
<div class="protocol-content">
<p>MCP (Model Context Protocol) 是一个开放标准允许AI应用安全地连接到外部数据源和工具</p>
<div class="protocol-features">
<div class="feature-item">
<n-icon :component="ShieldIcon" />
<span>安全可靠</span>
</div>
<div class="feature-item">
<n-icon :component="PlugIcon" />
<span>易于集成</span>
</div>
<div class="feature-item">
<n-icon :component="CodeIcon" />
<span>开源开放</span>
</div>
</div>
<div class="protocol-links">
<n-button text type="primary" tag="a" href="https://modelcontextprotocol.io" target="_blank">
了解更多
</n-button>
<n-button text type="primary" tag="a" href="https://github.com/modelcontextprotocol" target="_blank">
GitHub
</n-button>
</div>
</div>
</n-card>
</div>
<!-- 添加/编辑服务器模态框 -->
<n-modal v-model:show="showAddModal">
<n-card
style="width: 600px"
:title="editingServer ? '编辑MCP服务器' : '添加MCP服务器'"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<ServerForm
:server="editingServer"
@submit="handleServerSubmit"
@cancel="handleServerCancel"
/>
</n-card>
</n-modal>
<!-- 工具执行模态框 -->
<n-modal v-model:show="showToolModal">
<n-card
style="width: 700px"
:title="`执行工具: ${selectedTool?.name}`"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<ToolExecutor
v-if="selectedTool"
:server-id="selectedServerId"
:tool="selectedTool"
@close="showToolModal = false"
/>
</n-card>
</n-modal>
<!-- 服务器详情页面 -->
<n-modal
v-model:show="showServerDetail"
:style="{ padding: 0 }"
transform-origin="center"
>
<n-card
style="width: 90vw; max-width: 1200px; max-height: 90vh; overflow: auto;"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<MCPServerDetail
v-if="editingServer"
:server="editingServer"
@back="showServerDetail = false"
@save="handleServerDetailSave"
@toggle-server="handleToggleServerFromDetail"
@toggle-tool="handleToggleToolFromDetail"
@toggle-auto-approve="handleToggleAutoApproveFromDetail"
/>
</n-card>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
NButton,
NIcon,
NInput,
NSelect,
NTag,
NCard,
NModal,
NDropdown,
useMessage
} from 'naive-ui'
import {
Plus as PlusIcon,
Refresh as RefreshIcon,
Search as SearchIcon,
Tool as ToolIcon,
File as FileIcon,
Template as TemplateIcon,
Edit as EditIcon,
DotsVertical as DotsIcon,
Server as ServerIcon,
Shield as ShieldIcon,
Plug as PlugIcon,
Code as CodeIcon,
Activity as TestIcon,
Check as CheckIcon
} from '@vicons/tabler'
import { useServerStore } from '../stores/newServer'
import ServerForm from './ServerForm.vue'
import ToolExecutor from './ToolExecutor.vue'
import MCPServerDetail from './MCPServerDetail.vue'
const message = useMessage()
const serverStore = useServerStore()
// 组件挂载时自动重连之前已连接的服务器
onMounted(async () => {
console.log('🚀 MCPSettings 组件已挂载,开始自动重连...')
try {
await serverStore.autoReconnect()
} catch (error) {
console.error('自动重连失败:', error)
}
})
// 响应式数据
const showAddModal = ref(false)
const showToolModal = ref(false)
const showServerDetail = ref(false)
const editingServer = ref<any>(null)
const selectedTool = ref<any>(null)
const selectedServerId = ref<string>('')
const searchText = ref('')
const filterStatus = ref<string>('')
const expandedTools = ref<string[]>([])
const refreshing = ref(false)
const testing = ref(false)
const checking = ref(false)
const testingServerId = ref<string>('')
// 计算属性
const servers = computed(() => serverStore.servers)
const connectedServers = computed(() => serverStore.connectedServers)
const availableTools = computed(() => serverStore.availableTools)
const filteredServers = computed(() => {
let filtered = servers.value
// 文本搜索
if (searchText.value) {
const search = searchText.value.toLowerCase()
filtered = filtered.filter(server =>
server.name.toLowerCase().includes(search) ||
server.url.toLowerCase().includes(search) ||
server.description?.toLowerCase().includes(search)
)
}
// 状态筛选
if (filterStatus.value) {
filtered = filtered.filter(server => server.status === filterStatus.value)
}
return filtered
})
// 状态筛选选项
const statusFilters = [
{ label: '已连接', value: 'connected' },
{ label: '未连接', value: 'disconnected' },
{ label: '连接中', value: 'connecting' },
{ label: '错误', value: 'error' }
]
// 方法
const getServerIcon = (type: string) => {
const icons = {
http: ServerIcon,
sse: ServerIcon,
websocket: ServerIcon
}
return icons[type as keyof typeof icons] || ServerIcon
}
const getStatusType = (status: string): 'success' | 'error' | 'info' | 'default' => {
const types: Record<string, 'success' | 'error' | 'info' | 'default'> = {
connected: 'success',
disconnected: 'default',
connecting: 'info',
error: 'error'
}
return types[status] || 'default'
}
const getStatusText = (status: string) => {
const texts = {
connected: '已连接',
disconnected: '未连接',
connecting: '连接中...',
error: '连接失败'
}
return texts[status as keyof typeof texts] || status
}
const connectServer = async (serverId: string) => {
const server = servers.value.find(s => s.id === serverId)
if (!server) return
console.log(`🔗 尝试连接服务器: ${server.name} (${server.url})`)
try {
await serverStore.connectServer(serverId)
message.success(`服务器 ${server.name} 连接成功`)
} catch (error) {
console.error('连接失败:', error)
message.error(`服务器 ${server.name} 连接失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
const disconnectServer = async (serverId: string) => {
try {
await serverStore.disconnectServer(serverId)
message.success('服务器已断开连接')
} catch (error) {
message.error('断开连接失败')
}
}
const testServer = async (serverId: string) => {
testingServerId.value = serverId
try {
const server = servers.value.find(s => s.id === serverId)
if (!server) {
throw new Error('服务器不存在')
}
// 真实连接测试
console.log(`🔍 测试服务器连接: ${server.name} (${server.url}), 类型: ${server.type}`)
// 根据服务器类型使用不同的测试方法
if (server.type === 'sse') {
// SSE服务器尝试建立SSE连接
const testUrl = server.url.replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost')
console.log(`🔍 测试SSE连接: ${testUrl}`)
// 简单的健康检查 - 尝试GET请求
const response = await fetch(testUrl, {
method: 'GET',
headers: { 'Accept': 'text/event-stream' }
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
// SSE连接成功建立关闭连接
response.body?.cancel()
} else {
// HTTP服务器发送JSON-RPC请求到 /mcp 端点
let testUrl = server.url.replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost')
// 确保URL包含 /mcp 路径
if (!testUrl.includes('/mcp')) {
testUrl = testUrl.replace(/\/$/, '') + '/mcp'
}
console.log(`🔍 测试HTTP连接: ${testUrl}`)
const response = await fetch(testUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 'test-' + Date.now(),
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcp-test-client', version: '1.0.0' }
}
})
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(data.error.message || '服务器返回错误')
}
}
console.log(`✅ 服务器 ${server.name} 测试成功`)
message.success(`服务器 ${server.name} 连接测试成功`)
} catch (error) {
console.error(`❌ 服务器测试失败:`, error)
message.error(`服务器连接测试失败: ${error instanceof Error ? error.message : '未知错误'}`)
} finally {
testingServerId.value = ''
}
}
const openServerDetail = (server: any) => {
console.log('🔍 [1] 打开服务器详情被调用')
console.log('🔍 [2] 服务器数据:', server)
console.log('🔍 [3] 当前 showServerDetail 值:', showServerDetail.value)
try {
editingServer.value = { ...server } // 创建副本避免直接修改
console.log('🔍 [4] editingServer 设置完成:', editingServer.value)
showServerDetail.value = true
console.log('✅ [5] showServerDetail 设置为 true')
console.log('✅ [6] 最终状态检查 - showServerDetail:', showServerDetail.value, 'editingServer:', editingServer.value)
} catch (error) {
console.error('❌ openServerDetail 出错:', error)
}
}
const refreshAllServers = async () => {
refreshing.value = true
try {
// 刷新所有连接的服务器
const connectedIds = connectedServers.value.map(s => s.id)
await Promise.all(connectedIds.map(id => serverStore.connectServer(id)))
message.success('所有服务器已刷新')
} catch (error) {
message.error('刷新失败')
} finally {
refreshing.value = false
}
}
const testAllConnections = async () => {
testing.value = true
try {
console.log('🔍 开始测试所有服务器连接...')
// 重置所有服务器状态为未连接
servers.value.forEach(server => {
if (server.status === 'connected') {
server.status = 'disconnected'
server.capabilities = undefined
}
})
// 测试所有服务器连接
const results = await Promise.allSettled(
servers.value.map(server => testServer(server.id))
)
const successful = results.filter(r => r.status === 'fulfilled').length
const failed = results.filter(r => r.status === 'rejected').length
message.success(`连接测试完成: ${successful} 成功, ${failed} 失败`)
} catch (error) {
message.error('连接测试失败')
} finally {
testing.value = false
}
}
const checkAllStatus = async () => {
checking.value = true
try {
console.log('🔍 检查所有服务器真实连接状态...')
await serverStore.refreshAllStatus()
message.success('所有服务器状态已更新')
} catch (error) {
message.error('状态检查失败')
} finally {
checking.value = false
}
}
const toggleToolsList = (serverId: string) => {
const index = expandedTools.value.indexOf(serverId)
if (index > -1) {
expandedTools.value.splice(index, 1)
} else {
expandedTools.value.push(serverId)
}
}
const executeToolModal = (serverId: string, tool: any) => {
selectedServerId.value = serverId
selectedTool.value = tool
showToolModal.value = true
}
const handleServerSubmit = async (serverData: any) => {
try {
if (editingServer.value) {
// 编辑现有服务器 - 这里需要实现更新方法
message.success('服务器配置已更新')
} else {
await serverStore.addServer(serverData)
message.success('服务器添加成功')
}
handleServerCancel()
} catch (error) {
message.error('操作失败')
}
}
const handleServerCancel = () => {
editingServer.value = null
showAddModal.value = false
}
const getServerMenuOptions = (server: any) => [
{
label: '复制配置',
key: 'copy',
props: {
onClick: () => copyServerConfig(server)
}
},
{
label: '导出配置',
key: 'export',
props: {
onClick: () => exportServerConfig(server)
}
},
{
type: 'divider'
},
{
label: '删除',
key: 'delete',
props: {
onClick: () => deleteServer(server.id)
}
}
]
const handleServerAction = (_key: string) => {
// 处理服务器菜单操作 - 目前由各个选项的onClick直接处理
}
const copyServerConfig = (server: any) => {
navigator.clipboard.writeText(JSON.stringify(server, null, 2))
message.success('配置已复制到剪贴板')
}
const exportServerConfig = (server: any) => {
const config = JSON.stringify(server, null, 2)
const blob = new Blob([config], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${server.name}-config.json`
a.click()
URL.revokeObjectURL(url)
message.success('配置已导出')
}
const deleteServer = async (serverId: string) => {
try {
await serverStore.removeServer(serverId)
message.success('服务器已删除')
} catch (error) {
message.error('删除失败')
}
}
const addExampleServer = async () => {
const exampleServer = {
name: '示例 MCP 服务器',
url: 'http://localhost:3000/mcp',
type: 'http' as const,
description: '这是一个示例MCP服务器配置您可以根据需要修改URL和设置。',
enabled: true,
settings: {
autoConnect: false,
retryAttempts: 3,
timeout: 30
}
}
try {
await serverStore.addServer(exampleServer)
message.success('示例服务器已添加,您可以修改配置并测试连接')
} catch (error) {
// 即使连接失败也要添加到列表中,方便用户修改配置
message.info('示例服务器已添加到列表请修改URL后再进行连接')
}
}
const handleServerDetailSave = async (serverData: any) => {
try {
await serverStore.updateServer(serverData.id, serverData)
message.success('服务器配置已保存')
showServerDetail.value = false
} catch (error) {
message.error('保存失败')
}
}
const handleToggleServerFromDetail = async (serverId: string, enabled: boolean) => {
try {
await serverStore.updateServer(serverId, { enabled })
if (enabled) {
await serverStore.connectServer(serverId)
} else {
await serverStore.disconnectServer(serverId)
}
} catch (error) {
message.error('操作失败')
}
}
const handleToggleToolFromDetail = async (_serverId: string, toolName: string, enabled: boolean) => {
// TODO: 实现工具启用/禁用逻辑
message.info(`${enabled ? '启用' : '禁用'}工具: ${toolName}`)
}
const handleToggleAutoApproveFromDetail = async (_serverId: string, toolName: string, autoApprove: boolean) => {
// TODO: 实现工具自动批准逻辑
message.info(`${autoApprove ? '启用' : '禁用'}自动批准: ${toolName}`)
}
</script>
<style scoped>
.mcp-settings-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.header-info h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
}
.header-info p {
margin: 0;
color: var(--text-color-2);
font-size: 16px;
}
.header-stats {
display: flex;
gap: 24px;
}
.stat-item {
text-align: center;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: 600;
color: var(--primary-color);
}
.stat-label {
display: block;
font-size: 12px;
color: var(--text-color-3);
margin-top: 4px;
}
.quick-actions {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.servers-section {
margin-bottom: 32px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.section-controls {
display: flex;
gap: 12px;
}
.servers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 16px;
}
.server-card {
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
}
.server-card:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.server-card.connected {
border-color: var(--success-color);
}
.server-card.error {
border-color: var(--error-color);
}
.server-card.connecting {
border-color: var(--info-color);
}
.server-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.server-info {
display: flex;
align-items: center;
gap: 12px;
}
.server-avatar {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--primary-color-suppl);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-color);
}
.server-details h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
}
.server-details p {
margin: 0;
font-size: 12px;
color: var(--text-color-3);
word-break: break-all;
}
.server-description {
margin-bottom: 16px;
}
.server-description p {
margin: 0;
font-size: 14px;
color: var(--text-color-2);
}
.server-capabilities {
margin-bottom: 16px;
}
.capabilities-grid {
display: flex;
gap: 16px;
}
.capability-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-color-2);
}
.server-tools {
margin-bottom: 16px;
padding: 12px;
background: var(--hover-color);
border-radius: 8px;
}
.tools-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.tools-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-item {
display: flex;
flex-direction: column;
padding: 6px 8px;
background: var(--card-color);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.tool-item:hover {
background: var(--primary-color-suppl);
}
.tool-name {
font-size: 12px;
font-weight: 500;
}
.tool-desc {
font-size: 11px;
color: var(--text-color-3);
}
.tools-more {
text-align: center;
font-size: 12px;
color: var(--text-color-3);
padding: 6px;
}
.server-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.empty-state {
grid-column: 1 / -1;
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.empty-content {
text-align: center;
color: var(--text-color-3);
}
.empty-content h3 {
margin: 16px 0 8px 0;
font-size: 18px;
}
.empty-content p {
margin: 0 0 16px 0;
font-size: 14px;
}
.quick-start {
display: flex;
gap: 12px;
justify-content: center;
}
.protocol-info {
margin-top: 32px;
}
.protocol-content p {
margin: 0 0 16px 0;
color: var(--text-color-2);
}
.protocol-features {
display: flex;
gap: 24px;
margin-bottom: 16px;
}
.feature-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-color-2);
}
.protocol-links {
display: flex;
gap: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 16px;
}
.header-stats {
align-self: stretch;
justify-content: space-around;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.section-controls {
width: 100%;
justify-content: space-between;
}
.servers-grid {
grid-template-columns: 1fr;
}
.quick-actions {
flex-wrap: wrap;
}
.protocol-features {
flex-direction: column;
gap: 12px;
}
}
</style>