Files
Nexus/frontend/src/pages/ServersPage.vue
T
Nexus Agent e833b923c8 feat(browser): 远程浏览器 Worker 桥接与顶栏固定悬浮窗
接入 Playwright Worker、会话 WebSocket 与全局浏览器面板(固定于 App Bar 下);
含验证码 stealth、设置项默认音与 URL 安全校验;附带 worker 部署与设计文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 06:49:20 +08:00

1942 lines
65 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>
<v-container fluid class="pa-4 pa-md-6">
<StatCardsRow
:items="statItemsDisplay"
:loading="statsBooting"
:refreshing="statsRefreshing"
@click="onStatCardClick"
@refresh="refreshStatCards"
/>
<WatchSlotRow />
<div v-if="statusFilterLabel || unsetPathFocus" class="d-flex align-center flex-wrap ga-2 mb-3">
<v-chip
v-if="statusFilterLabel"
color="primary"
variant="tonal"
size="small"
closable
label
@click:close="clearStatusFilter"
>
{{ statusFilterLabel }}
</v-chip>
<v-chip
v-if="unsetPathFocus"
color="warning"
variant="tonal"
size="small"
closable
label
@click:close="clearUnsetPathFocus"
>
已定位未设置路径服务器
</v-chip>
<span class="text-caption text-medium-emphasis">点击顶部统计卡可切换筛选或定位</span>
</div>
<!-- Server Table -->
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
服务器列表
<v-spacer />
<v-combobox
v-model="searchDraft"
:items="serverSearchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索名称/IP(含未设路径)"
density="compact"
hide-details
rounded
clearable
:auto-select-first="false"
style="max-width: 240px"
variant="outlined"
placeholder="输入后回车,或从历史选择"
@update:model-value="onSearchComboboxUpdate"
@keydown.enter.prevent="commitSearch"
/>
<v-btn color="primary" variant="flat" class="ml-3" prepend-icon="mdi-plus" size="small" @click="openAddServer">
添加
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
快速添加(IP)
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
批量添加
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-key-outline" size="small" @click="showCredentialsDialog = true">
凭据
</v-btn>
<v-btn
variant="tonal"
class="ml-2"
prepend-icon="mdi-folder-search"
size="small"
:disabled="selectedIds.size === 0"
title="请先勾选至少一台服务器"
@click="openDetectPathConfirm('main')"
>
检测路径
</v-btn>
<v-btn-toggle v-model="viewMode" mandatory density="compact" class="ml-2" color="primary" variant="outlined" divided>
<v-btn value="table" size="small" prepend-icon="mdi-table">列表</v-btn>
<v-btn value="group" size="small" prepend-icon="mdi-folder-multiple">分类</v-btn>
</v-btn-toggle>
</v-card-title>
<v-card-text class="pt-0 pb-2">
<v-chip
v-if="search.trim()"
size="small"
variant="tonal"
color="info"
label
class="mb-2"
>
全库搜索中含未设路径与连接失败已忽略分类/在线筛选
</v-chip>
<div class="d-flex align-center flex-wrap ga-2">
<span class="text-caption text-medium-emphasis mr-1">分类筛选</span>
<v-chip
:color="categoryFilter === '' ? 'primary' : undefined"
:variant="categoryFilter === '' ? 'flat' : 'outlined'"
size="small"
label
@click="selectCategory('')"
>
全部 {{ totalCount }}
</v-chip>
<v-chip
v-for="cat in categoryList"
:key="cat.name"
:color="categoryFilter === cat.name ? 'primary' : undefined"
:variant="categoryFilter === cat.name ? 'flat' : 'outlined'"
size="small"
label
@click="selectCategory(cat.name)"
>
{{ cat.label }} {{ cat.count }}
</v-chip>
<v-progress-circular v-if="categoriesLoading" indeterminate size="16" width="2" />
<v-spacer />
<v-btn
size="small"
variant="tonal"
:disabled="total === 0"
:loading="selectAllFilteredLoading"
@click="selectAllFiltered"
>
全选筛选结果 ({{ total }})
</v-btn>
</div>
</v-card-text>
<!-- Batch Actions -->
<ServerBatchActionBar
:count="selectedIds.size"
@batch-category="openBatchCategory('main')"
@health-check="batchHealthCheck('main')"
@detect-path="openDetectPathConfirm('main')"
@install-agent="batchInstallAgent('main')"
@upgrade-agent="batchUpgradeAgent('main')"
@uninstall-agent="batchUninstallAgent('main')"
@batch-delete="confirmBatchDelete('main')"
@clear="selectedItems = []"
/>
<v-skeleton-loader
v-if="viewMode === 'table' && loading && !servers.length"
type="table-heading, table-row-divider@8"
class="mb-4"
/>
<v-data-table-server
v-if="viewMode === 'table'"
v-show="!(loading && !servers.length)"
:items="servers"
:headers="headers"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
item-value="id"
show-select
v-model="selectedItems"
v-model:sort-by="sortBy"
v-model:expanded="expandedMainIdsModel"
show-expand
class="server-main-table"
@update:page="onMainPageChange"
@update:items-per-page="onMainItemsPerPageChange"
>
<template #item.data-table-expand />
<template #item.status="{ item }">
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ statusLabel(item.status) }}
</v-chip>
</template>
<template #item.name="{ item }">
<div
class="d-flex align-center cursor-pointer text-primary"
@click.stop="toggleMainExpand(item)"
>
<v-icon size="small" class="mr-1">
{{ expandedMainIds.includes(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="font-weight-medium">{{ item.name }}</span>
</div>
</template>
<template #item.domain="{ item }">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
</template>
<template #item.category="{ item }">
<v-chip v-if="item.category" color="primary" variant="tonal" size="small" label>
{{ item.category }}
</v-chip>
<span v-else class="text-medium-emphasis"></span>
</template>
<template #item.target_path="{ item }">
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1" @click.stop>
<v-text-field
v-model="editingPathValue"
density="compact"
variant="outlined"
hide-details
placeholder="/www/wwwroot"
style="max-width: 140px"
@keyup.enter="saveTargetPath(item.id)"
@keyup.esc="cancelEditPath"
/>
<v-btn icon="mdi-check" size="x-small" variant="text" color="primary" :loading="savingPathId === item.id" @click="saveTargetPath(item.id)" />
<v-btn icon="mdi-close" size="x-small" variant="text" @click="cancelEditPath" />
</div>
<div v-else class="d-flex align-center ga-1">
<span
class="text-caption text-medium-emphasis text-truncate d-inline-block"
style="max-width: 140px"
:title="item.target_path || '未配置'"
>
{{ item.target_path?.trim() || '—' }}
</span>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
density="compact"
title="编辑目标路径"
@click.stop="startEditPath(item)"
/>
</div>
</template>
<template #item.agent_version="{ item }">
<div>
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
<v-chip
v-if="item.agent_action === 'install'"
size="x-small"
color="info"
variant="tonal"
label
class="mt-1"
>
需安装
</v-chip>
<v-chip
v-else-if="item.agent_action === 'upgrade'"
size="x-small"
color="warning"
variant="tonal"
label
class="mt-1"
>
需升级
</v-chip>
</div>
</template>
<template #item.last_heartbeat="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_heartbeat) }}</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1 align-center">
<v-btn
:icon="pinnedMap[item.id] != null ? 'mdi-check' : 'mdi-plus'"
size="x-small"
variant="text"
:color="pinnedMap[item.id] != null ? 'success' : 'primary'"
density="compact"
:title="pinnedMap[item.id] != null ? `已在监测 · 槽 ${pinnedMap[item.id]! + 1}` : '加入实时监测'"
:loading="pinLoadingId === item.id"
@click.stop="onPinServer(item)"
/>
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(item)">终端</v-btn>
<v-btn
v-if="item.domain"
variant="text"
size="x-small"
density="compact"
@click.stop="openBrowser(item)"
>
站点
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="openFiles(item)">文件</v-btn>
<v-btn variant="text" size="x-small" density="compact" :loading="agentDiagnoseLoadingId === item.id" @click.stop="openAgentDiagnose(item)">诊断</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="editServer(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click.stop="confirmDelete(item)">删除</v-btn>
</div>
</template>
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="pa-0">
<ServerInlineDetail
:server="item"
:active="expandedMainIds.includes(item.id)"
:agent-action-loading="agentActionLoadingId === item.id"
@edit-path="startEditPath(item)"
@install-agent="installAgentOne(item)"
@upgrade-agent="upgradeAgentOne(item)"
@diagnose-agent="openAgentDiagnose(item)"
/>
</td>
</tr>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无服务器数据</div>
</template>
</v-data-table-server>
<div v-else class="px-4 pb-4">
<div v-if="groupLoading" class="text-center py-8">
<v-progress-circular indeterminate size="32" />
<div class="text-caption text-medium-emphasis mt-2">按分类加载中</div>
</div>
<div v-else-if="groupedServers.length === 0" class="text-center text-medium-emphasis py-6">
暂无服务器数据
</div>
<v-expansion-panels v-else v-model="expandedCategories" multiple variant="accordion" class="servers-category-panels">
<v-expansion-panel v-for="group in groupedServers" :key="group.category">
<v-expansion-panel-title>
<div class="d-flex align-center ga-2 flex-grow-1">
<v-chip color="primary" variant="tonal" size="small" label>{{ group.label }}</v-chip>
<span class="text-caption text-medium-emphasis">{{ group.servers.length }} </span>
<v-spacer />
<v-btn
size="x-small"
variant="text"
@click.stop="toggleGroupAll(group.servers)"
>
{{ isGroupAllSelected(group.servers) ? '取消本组' : '全选本组' }}
</v-btn>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-table density="compact" hover>
<thead>
<tr>
<th style="width: 40px" />
<th>状态</th>
<th>名称</th>
<th>地址</th>
<th>目标路径</th>
<th class="text-end">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in group.servers"
:key="item.id"
>
<td @click.stop>
<v-checkbox-btn
:model-value="selectedIds.has(item.id)"
density="compact"
hide-details
@update:model-value="toggleGroupSelection(item.id, $event)"
/>
</td>
<td>
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ statusLabel(item.status) }}
</v-chip>
</td>
<td class="font-weight-medium">{{ item.name }}</td>
<td class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</td>
<td class="text-caption text-medium-emphasis text-truncate" style="max-width: 180px">
{{ item.target_path?.trim() || '—' }}
</td>
<td class="text-end" @click.stop>
<v-btn variant="text" size="x-small" density="compact" @click="editServer(item)">编辑</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card>
<ServerUnsetPathPanel
:highlight="unsetPathFocus"
:servers="unsetPathServers"
:loading="unsetPathLoading"
:total="unsetPathTotal"
:page="unsetPathPage"
:items-per-page="unsetPathItemsPerPage"
:items-per-page-options="dataTablePageOptions"
:expanded-ids="expandedUnsetIds"
v-model:selected-items="unsetSelectedItems"
v-model:sort-by="sortBy"
:select-all-loading="selectAllUnsetLoading"
:editing-path-id="editingPathId"
:editing-path-value="editingPathValue"
:saving-path-id="savingPathId"
@refresh="loadUnsetPathServers()"
@update:page="onUnsetPageChange"
@update:items-per-page="onUnsetItemsPerPageChange"
@update:expanded-ids="expandedUnsetIds = $event"
@update:editing-path-value="editingPathValue = $event"
@select-all-filtered="selectAllUnsetFiltered"
@toggle-expand="toggleUnsetExpand"
@terminal="openTerminal"
@files="openFiles"
@detect-path-single="detectPathSingle"
@agent-diagnose="openAgentDiagnose"
@edit="editServer"
@delete="confirmDelete"
@edit-path="startEditPath"
@save-path="saveTargetPath"
@cancel-edit-path="cancelEditPath"
@batch-category="openBatchCategory('unset')"
@health-check="batchHealthCheck('unset')"
@detect-path="openDetectPathConfirm('unset')"
@install-agent="batchInstallAgent('unset')"
@upgrade-agent="batchUpgradeAgent('unset')"
@uninstall-agent="batchUninstallAgent('unset')"
@batch-delete="confirmBatchDelete('unset')"
@clear-selection="unsetSelectedItems = []"
/>
<!-- Pending / failed SSH connections -->
<v-card id="pending-servers-panel" elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
连接失败列表
<v-chip v-if="pendingServers.length" size="small" class="ml-2" color="error" variant="tonal" label>
{{ pendingServers.length }}
</v-chip>
<v-spacer />
<v-btn variant="text" size="small" prepend-icon="mdi-refresh" :loading="pendingLoading" @click="loadPendingServers">
刷新
</v-btn>
</v-card-title>
<v-data-table
:items="pendingServers"
:headers="pendingHeaders"
:loading="pendingLoading"
hover
density="comfortable"
:items-per-page="10"
>
<template #item.address="{ item }">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
</template>
<template #item.last_attempt_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_attempt_at) }}</span>
</template>
<template #item.last_error="{ item }">
<span class="text-caption text-error text-truncate d-inline-block" style="max-width: 320px" :title="item.last_error">
{{ item.last_error || '—' }}
</span>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" color="primary" density="compact" :loading="retryingId === item.id" @click="retryPending(item)">
重试
</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDeletePending(item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无连接失败记录</div>
</template>
</v-data-table>
</v-card>
<ServerFormDialog
v-model="serverFormVisible"
:form="serverForm"
:is-editing="isEditingServer"
:saving="serverFormSaving"
:lookups-loading="serverFormLookupsLoading"
:password-presets="passwordPresets"
:ssh-key-presets="sshKeyPresets"
:category-options="categoryOptions"
:platforms="serverFormPlatforms"
:nodes="serverFormNodes"
:edit-hints="serverFormEditHints"
:files-elevation-items="filesElevationItems"
@save="saveServerForm"
/>
<!-- Delete Confirm -->
<v-dialog v-model="showDelete" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>确定要删除服务器 <strong>{{ deletingServer?.name }}</strong> </v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDelete" :loading="deleting">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Quick add by IP -->
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
<v-card border>
<v-card-title>快速添加服务器 IP</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-3">
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录首个成功即加入服务器列表
</p>
<v-text-field
v-model="quickAddForm.domain"
label="IP 或域名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="192.168.1.10"
:rules="[v => !!v?.trim() || '必填']"
/>
<v-text-field
v-model.number="quickAddForm.port"
label="SSH 端口"
variant="outlined"
density="compact"
type="number"
class="mb-2"
/>
<v-text-field
v-model="quickAddForm.name"
label="显示名称(可选)"
variant="outlined"
density="compact"
hint="留空则使用 IP"
persistent-hint
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Poll failure detail -->
<v-dialog v-model="showPollFailure" max-width="560">
<v-card border>
<v-card-title class="text-error">SSH 连接失败</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">已尝试全部凭据预设均未登录成功该服务器已加入连接失败列表」,可稍后重试</p>
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
<v-list-item-title class="text-body-2">
[{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}] {{ err.preset_name }} ({{ err.username }})
</v-list-item-title>
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete pending confirm -->
<v-dialog v-model="showDeletePending" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>从失败列表移除 <strong>{{ deletingPending?.domain }}</strong></v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeletePending = false">取消</v-btn>
<v-btn color="error" variant="flat" :loading="deletingPendingLoading" @click="doDeletePending">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Detect target_path confirm -->
<v-dialog v-model="showDetectPathConfirm" max-width="480">
<v-card border>
<v-card-title>自动检测目标路径</v-card-title>
<v-card-text>
<p class="text-body-2 mb-2">
将对选中的 <strong>{{ scopedSelectedCount }}</strong> 台服务器执行 SSH 检测
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
在远程 <code>/www/wwwroot</code> 下搜索 <code>workerman.bat</code>找到后将所在目录写入各服务器的
<strong>target_path</strong>找到第一个即停止)。
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDetectPathConfirm = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="runBatchDetectPath">开始检测</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Batch category -->
<v-dialog v-model="showBatchCategory" max-width="420">
<v-card border>
<v-card-title>批量修改分类</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
已选择 {{ scopedSelectedCount }} 台服务器将统一设置为以下分类留空表示清除分类)。
</div>
<v-combobox
v-model="batchCategoryValue"
:items="batchCategoryOptions"
label="分类"
variant="outlined"
density="compact"
clearable
hide-details
placeholder="输入或选择分类"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showBatchCategory = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="batchCategoryLoading" @click="submitBatchCategory">
确认
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Batch add by IP dialog -->
<v-dialog v-model="showBatchAdd" max-width="560">
<v-card border>
<v-card-title>批量添加服务器</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
每行一个 IP 或域名也可使用名称 + Tab + IP附带显示名称空白行自动忽略重复地址自动去重不区分大小写)。SSH 凭据从凭据预设轮询匹配连接失败将进入下方连接失败列表」。
</div>
<v-textarea
v-model="batchAddText"
label="IP / 域名列表(可选:名称 Tab IP)"
variant="outlined"
density="compact"
rows="10"
auto-grow
placeholder="武汉示例公司 192.168.1.10&#10;192.168.1.11&#10;10.0.0.5"
/>
<div v-if="batchAddPreview.unique > 0" class="text-caption text-medium-emphasis mt-1">
将添加 {{ batchAddPreview.unique }}
<span v-if="batchAddPreview.deduped > 0">已去重 {{ batchAddPreview.deduped }} 个重复</span>
</div>
<v-row dense class="mt-1">
<v-col cols="6">
<v-text-field v-model.number="batchAddPort" label="SSH 端口" type="number" variant="outlined" density="compact" hide-details />
</v-col>
<v-col cols="6">
<v-text-field v-model.number="batchAddAgentPort" label="Agent 端口" type="number" variant="outlined" density="compact" hide-details />
</v-col>
</v-row>
<v-alert
v-if="batchAddResult"
:type="batchAddResult.pending > 0 ? 'warning' : 'success'"
density="compact"
variant="tonal"
class="mt-3"
>
{{ batchAddResult.total }} 成功 {{ batchAddResult.created }}待连接 {{ batchAddResult.pending }}跳过 {{ batchAddResult.skipped }}
<span v-if="(batchAddResult.duplicates_removed ?? 0) > 0">
输入 {{ batchAddResult.input_lines }} 去重 {{ batchAddResult.duplicates_removed }}
</span>
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showBatchAdd = false">关闭</v-btn>
<v-btn color="primary" variant="flat" @click="submitBatchAdd" :loading="batchAddLoading" :disabled="!batchAddText.trim()">
开始添加
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<CredentialsDialog v-model="showCredentialsDialog" />
<AgentDiagnoseDialog
v-model="showAgentDiagnose"
:loading="agentDiagnoseLoading"
:result="agentDiagnoseResult"
:target-name="agentDiagnoseTarget?.name"
/>
<v-dialog v-model="showReplacePin" max-width="420">
<v-card rounded="lg">
<v-card-title>监测槽已满</v-card-title>
<v-card-text class="text-body-2">
请选择要替换的槽位当前 Pin 的服务将被移除):
</v-card-text>
<v-card-text>
<v-row dense>
<v-col v-for="slot in watchSlots" :key="slot.slot_index" cols="6">
<v-btn
block
variant="tonal"
:color="slot.empty ? 'primary' : 'warning'"
@click="confirmReplaceSlot(slot.slot_index)"
>
{{ slot.slot_index + 1 }}
<span v-if="!slot.empty" class="text-caption ml-1">({{ slot.server_name }})</span>
<span v-else class="text-caption ml-1">()</span>
</v-btn>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showReplacePin = false">取消</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, type Ref } from 'vue'
import { usePageAutoRefresh } from '@/composables/usePageAutoRefresh'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { CACHE_KEYS, cachedFetch, invalidateCachedQuery } from '@/composables/useCachedQuery'
import { toServerApiSort } from '@/utils/serverTableSort'
import { useRouter, useRoute } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
import WatchSlotRow from '@/components/watch/WatchSlotRow.vue'
import { useWatchPins } from '@/composables/useWatchPins'
import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerUnsetPathPanel from '@/components/servers/ServerUnsetPathPanel.vue'
import CredentialsDialog from '@/components/credentials/CredentialsDialog.vue'
import AgentDiagnoseDialog from '@/components/servers/AgentDiagnoseDialog.vue'
import type { AgentDiagnoseResult } from '@/types/agentDiagnose'
import { useServerFormDialog } from '@/composables/servers/useServerFormDialog'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
import {
SERVER_STAT_CARD_KEYS,
applyServerStats,
fetchServerStats,
refreshServerStats,
useServerStatsCards,
} from '@/composables/useServerStatsCards'
async function submitServerBatchJob(
op: string,
serverIds: number[],
params?: Record<string, unknown>,
): Promise<BatchJobStarted | null> {
const paths: Record<string, string> = {
category: '/servers/batch/category',
'health-check': '/servers/check',
'detect-path': '/servers/batch/detect-path',
'install-agent': '/servers/batch/install-agent',
'upgrade-agent': '/servers/batch/upgrade-agent',
'uninstall-agent': '/servers/batch/uninstall-agent',
}
const path = paths[op]
if (!path) return null
const body = op === 'category'
? { server_ids: serverIds, ...params }
: { server_ids: serverIds }
const res = await http.post<BatchJobStarted & { results?: unknown[] }>(path, body)
if (!res?.job_id) {
throw new Error('服务端未返回任务 ID,请确认已部署支持后台批量任务的新版本')
}
registerServerBatchJob(
res.job_id,
res.label,
res.total,
res.op || op,
res.progress,
res.status,
)
showScriptSubmitToast(`${res.label} 已提交`)
return res
}
function registerOnboardingJob(
jobId: number | undefined,
label: string | undefined,
total: number,
) {
if (!jobId) return
registerServerBatchJob(
jobId,
label || '新服务器初始化',
total,
'onboard',
`0/${total}`,
'running',
)
showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)')
}
onMounted(() => {
offBatchComplete = onScriptExecutionComplete((alert) => {
if (alert.taskKind !== 'server_batch') return
invalidateCachedQuery(CACHE_KEYS.serverCategories)
void Promise.all([loadServers(true), loadUnsetPathServers(true), loadCategories(), loadStats(true)])
})
})
let offBatchComplete: (() => void) | null = null
onBeforeUnmount(() => {
offBatchComplete?.()
})
defineOptions({ name: 'ServersPage' })
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const { pinnedMap, pinServer: doPinServer, slots: watchSlots } = useWatchPins()
const pinLoadingId = ref<number | null>(null)
const showReplacePin = ref(false)
const replacePinServerId = ref<number | null>(null)
async function onPinServer(item: ServerApiItem) {
const existingSlot = pinnedMap.value[item.id]
if (existingSlot != null) {
snackbar(`已在监测 · 槽 ${existingSlot + 1}`, 'info')
return
}
pinLoadingId.value = item.id
try {
const res = await doPinServer(item.id)
if (res.ok) {
snackbar('已加入实时监测', 'success')
return
}
if (res.full) {
replacePinServerId.value = item.id
showReplacePin.value = true
return
}
snackbar(res.error || '加入监测失败', 'error')
} finally {
pinLoadingId.value = null
}
}
async function confirmReplaceSlot(slotIndex: number) {
if (replacePinServerId.value == null) return
pinLoadingId.value = replacePinServerId.value
try {
const res = await doPinServer(replacePinServerId.value, slotIndex)
if (res.ok) {
snackbar(`已替换槽 ${slotIndex + 1}`, 'success')
showReplacePin.value = false
replacePinServerId.value = null
} else {
snackbar(res.error || '替换失败', 'error')
}
} finally {
pinLoadingId.value = null
}
}
const router = useRouter()
const globalBrowser = useGlobalBrowser()
const route = useRoute()
const showCredentialsDialog = ref(false)
function openCredentialsFromQuery() {
if (route.query.credentials === '1') {
showCredentialsDialog.value = true
const q = { ...route.query }
delete q.credentials
router.replace({ path: route.path, query: q })
}
}
watch(() => route.query.credentials, () => openCredentialsFromQuery(), { immediate: true })
// ── Paginated server list (composable) ──
const {
servers, loading, page, itemsPerPage, total, search, categoryFilter, isOnlineFilter, sortBy,
loadServers: _load, onPageChange, onItemsPerPageChange, onCategoryChange,
onOnlineFilterChange, clearOnlineFilter,
loadAllServers, listQueryParams,
} = useServerPagination({ targetPathUnset: false })
const { items: serverSearchHistory, record: recordServerSearch, loadHistoryOnly: loadServerSearchHistory } =
useServerSearchHistory()
/** Combobox draft; applied filter is `search` from useServerPagination. */
const searchDraft = ref('')
function commitSearch() {
const q = String(searchDraft.value ?? '').trim()
if (q === String(search.value ?? '').trim()) return
search.value = q
searchDraft.value = q
page.value = 1
unsetPathPage.value = 1
void recordServerSearch(q)
void loadServers()
void loadPendingServers(true)
}
function onSearchComboboxUpdate(val: string | null) {
searchDraft.value = val ?? ''
if (val == null || val === '') {
if (!String(search.value ?? '').trim()) return
search.value = ''
page.value = 1
unsetPathPage.value = 1
void recordServerSearch('')
void loadServers()
void loadPendingServers(true)
return
}
const picked = String(val).trim()
if (serverSearchHistory.value.includes(picked)) {
search.value = picked
searchDraft.value = picked
page.value = 1
unsetPathPage.value = 1
void recordServerSearch(picked)
void loadServers()
void loadPendingServers(true)
}
}
const unsetPathServers = ref<ServerApiItem[]>([])
const unsetPathTotal = ref(0)
const unsetPathLoading = ref(false)
const unsetPathPage = ref(1)
const unsetPathItemsPerPage = ref(10)
const expandedMainIds = ref<number[]>([])
const expandedUnsetIds = ref<number[]>([])
/** v-data-table 运行时按 item-value(数字 id)匹配,类型声明为 string[] */
function expandedTableModel(source: Ref<number[]>) {
return computed({
get: () => source.value as unknown as readonly string[],
set: (ids: readonly string[]) => {
source.value = ids.map((id) => Number(id)).filter((n) => !Number.isNaN(n))
},
})
}
const expandedMainIdsModel = expandedTableModel(expandedMainIds)
const unsetSelectedItems = ref<Array<ServerApiItem | number>>([])
const unsetSelectedIds = computed(() => new Set(normalizeServerIds(unsetSelectedItems.value)))
const selectAllUnsetLoading = ref(false)
type BatchScope = 'main' | 'unset'
const batchScope = ref<BatchScope>('main')
function batchSelection(scope: BatchScope) {
return scope === 'main'
? { items: selectedItems, ids: selectedIds }
: { items: unsetSelectedItems, ids: unsetSelectedIds }
}
const scopedSelectedCount = computed(() => batchSelection(batchScope.value).ids.value.size)
/** 未设路径区块独立列表:不受顶部分类 chip 过滤,避免改分类后机器从面板消失 */
function unsetListQueryParams(): Record<string, unknown> {
return {
search: search.value || undefined,
...toServerApiSort(sortBy.value),
target_path_unset: true,
}
}
async function loadAllUnsetPathServers(): Promise<ServerApiItem[]> {
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
unsetListQueryParams(),
1,
-1,
)
return res.items
}
async function selectAllUnsetFiltered() {
if (unsetPathTotal.value <= 0) return
selectAllUnsetLoading.value = true
try {
const all = await loadAllUnsetPathServers()
unsetSelectedItems.value = all.map(s => s.id)
snackbar(`已选择未设路径筛选结果 ${all.length} 台`)
} catch {
snackbar('加载未设路径筛选结果失败', 'error')
} finally {
selectAllUnsetLoading.value = false
}
}
async function loadUnsetPathServers(silent = false) {
if (!silent) unsetPathLoading.value = true
try {
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
unsetListQueryParams(),
unsetPathPage.value,
unsetPathItemsPerPage.value,
)
unsetPathServers.value = res.items
unsetPathTotal.value = res.total
} catch {
unsetPathServers.value = []
unsetPathTotal.value = 0
if (!silent) snackbar('加载未设置路径服务器失败', 'error')
} finally {
if (!silent) unsetPathLoading.value = false
}
}
function toggleMainExpand(item: ServerApiItem) {
expandedMainIds.value = expandedMainIds.value.includes(item.id) ? [] : [item.id]
}
function toggleUnsetExpand(item: ServerApiItem) {
expandedUnsetIds.value = expandedUnsetIds.value.includes(item.id) ? [] : [item.id]
}
function onMainPageChange(p: number) {
expandedMainIds.value = []
onPageChange(p)
}
function onMainItemsPerPageChange(n: number) {
expandedMainIds.value = []
onItemsPerPageChange(n)
}
function onUnsetPageChange(p: number) {
expandedUnsetIds.value = []
unsetSelectedItems.value = []
unsetPathPage.value = p
void loadUnsetPathServers()
}
function onUnsetItemsPerPageChange(n: number) {
expandedUnsetIds.value = []
unsetSelectedItems.value = []
unsetPathItemsPerPage.value = n
unsetPathPage.value = 1
void loadUnsetPathServers()
}
async function detectPathSingle(item: ServerApiItem) {
try {
await submitServerBatchJob('detect-path', [item.id])
} catch (e: unknown) {
snackbar(formatApiError(e, '提交检测任务失败'), 'error')
}
}
function clearExpandedForPathMigration(serverId: number, newPath: string) {
const unset = isUnsetTargetPath(newPath)
if (expandedMainIds.value.includes(serverId) && unset) expandedMainIds.value = []
if (expandedUnsetIds.value.includes(serverId) && !unset) expandedUnsetIds.value = []
}
const {
categories: categoryList,
loading: categoriesLoading,
totalCount,
loadCategories,
} = useServerCategories()
const viewMode = ref<'table' | 'group'>('table')
const groupServers = ref<ServerApiItem[]>([])
const groupLoading = ref(false)
const expandedCategories = ref<string[]>([])
async function loadServers(silent = false) {
try {
await _load({ silent })
await loadUnsetPathServers(silent)
if (viewMode.value === 'group') {
await loadGroupServers(silent)
}
} catch {
if (!silent) snackbar('加载服务器列表失败', 'error')
}
}
async function loadGroupServers(silent = false) {
if (!silent) groupLoading.value = true
try {
groupServers.value = await loadAllServers()
expandedCategories.value = groupedServers.value.map(g => g.category)
} catch {
if (!silent) {
groupServers.value = []
snackbar('加载分类视图失败', 'error')
}
} finally {
if (!silent) groupLoading.value = false
}
}
async function refreshServersSilent() {
try {
await _load({ silent: true })
await loadUnsetPathServers(true)
if (viewMode.value === 'group') await loadGroupServers(true)
} catch {
/* 后台刷新失败不打断用户操作 */
}
}
usePageAutoRefresh(refreshServersSilent, 30_000)
const groupedServers = computed(() => {
const groups: Record<string, ServerApiItem[]> = {}
for (const s of groupServers.value) {
const cat = s.category?.trim() || 'uncategorized'
if (!groups[cat]) groups[cat] = []
groups[cat].push(s)
}
return Object.entries(groups)
.map(([category, list]) => ({
category,
label: categoryDisplayLabel(category),
servers: list,
}))
.sort((a, b) => {
if (a.category === 'uncategorized') return 1
if (b.category === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
})
function selectCategory(name: string) {
onCategoryChange(name)
if (viewMode.value === 'group') {
void loadGroupServers()
}
}
function toggleGroupSelection(id: number, checked: boolean | null) {
const next = new Set(selectedItems.value.map(item => (typeof item === 'number' ? item : item.id)))
if (checked) next.add(id)
else next.delete(id)
selectedItems.value = [...next]
}
const selectAllFilteredLoading = ref(false)
async function selectAllFiltered() {
if (total.value <= 0) return
selectAllFilteredLoading.value = true
try {
const all = await loadAllServers()
selectedItems.value = all.map(s => s.id)
snackbar(`已选择当前筛选结果 ${all.length} 台`)
} catch {
snackbar('加载筛选结果失败', 'error')
} finally {
selectAllFilteredLoading.value = false
}
}
function isGroupAllSelected(groupServers: ServerApiItem[]): boolean {
return groupServers.length > 0 && groupServers.every(s => selectedIds.value.has(s.id))
}
function toggleGroupAll(groupServers: ServerApiItem[]) {
const allSelected = isGroupAllSelected(groupServers)
const next = new Set(selectedIds.value)
for (const s of groupServers) {
if (allSelected) next.delete(s.id)
else next.add(s.id)
}
selectedItems.value = [...next]
}
watch(categoryFilter, () => {
selectedItems.value = []
expandedMainIds.value = []
void loadServers()
})
watch(sortBy, () => {
selectedItems.value = []
unsetSelectedItems.value = []
expandedMainIds.value = []
expandedUnsetIds.value = []
unsetPathPage.value = 1
if (viewMode.value === 'group') void loadGroupServers()
void loadUnsetPathServers()
}, { deep: true })
watch(search, () => {
selectedItems.value = []
unsetSelectedItems.value = []
expandedMainIds.value = []
expandedUnsetIds.value = []
unsetPathPage.value = 1
})
watch(
() => [search.value, total.value, unsetPathTotal.value, pendingServers.value.length, loading.value] as const,
([q, mainTotal, unsetTotal, pendingCount, isLoading]) => {
if (!String(q ?? '').trim() || isLoading) return
if (mainTotal > 0 || unsetTotal > 0) return
if (pendingCount <= 0) return
void nextTick(() => {
document.getElementById('pending-servers-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
},
)
watch(viewMode, mode => {
if (mode === 'group') {
void loadGroupServers()
}
})
const showDelete = ref(false)
const deleting = ref(false)
const deletingServer = ref<ServerApiItem | null>(null)
// ── Quick add by IP + pending failure list ──
const showQuickAdd = ref(false)
const quickAddLoading = ref(false)
const quickAddForm = ref({ domain: '', port: 22, name: '' })
const pendingServers = ref<PendingServerItem[]>([])
const pendingLoading = ref(false)
const retryingId = ref<number | null>(null)
const showPollFailure = ref(false)
const pollFailureErrors = ref<PollErrorItem[]>([])
const pollFailureMessage = ref('')
const showDeletePending = ref(false)
const deletingPending = ref<PendingServerItem | null>(null)
const deletingPendingLoading = ref(false)
const pendingHeaders = [
{ title: '名称', key: 'name' },
{ title: '地址', key: 'address' },
{ title: '尝试次数', key: 'attempts', width: 100 },
{ title: '最后错误', key: 'last_error' },
{ title: '最后尝试', key: 'last_attempt_at', width: 160 },
{ title: '操作', key: 'actions', width: 140, align: 'end' as const },
]
function openQuickAdd() {
quickAddForm.value = { domain: '', port: 22, name: '' }
showQuickAdd.value = true
}
function showFailureDialog(errors: PollErrorItem[], message?: string) {
pollFailureErrors.value = errors
pollFailureMessage.value = message || ''
showPollFailure.value = true
}
async function loadPendingServers(silent = false) {
if (!silent) pendingLoading.value = true
try {
const q = String(search.value ?? '').trim()
const res = await http.get<{ items: PendingServerItem[]; total: number }>(
'/servers/pending',
q ? { search: q } : undefined,
)
pendingServers.value = res.items || []
} catch (e: unknown) {
pendingServers.value = []
snackbar(formatApiError(e, '加载失败列表失败'), 'error')
} finally {
if (!silent) pendingLoading.value = false
}
}
async function submitQuickAdd() {
const domain = quickAddForm.value.domain.trim()
if (!domain) {
snackbar('请填写 IP 或域名', 'error')
return
}
quickAddLoading.value = true
try {
const body: Record<string, unknown> = {
domain,
port: quickAddForm.value.port || 22,
}
const name = quickAddForm.value.name.trim()
if (name) body.name = name
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
if (res.success && res.server) {
showQuickAdd.value = false
registerOnboardingJob(res.onboarding_job_id, res.onboarding_label, 1)
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
loadServers()
loadStats()
} else {
showQuickAdd.value = false
showFailureDialog(res.errors || [], res.message)
loadPendingServers()
}
} catch (e: unknown) {
snackbar(formatApiError(e, '添加失败'), 'error')
} finally {
quickAddLoading.value = false
}
}
async function retryPending(item: PendingServerItem) {
retryingId.value = item.id
try {
const res = await http.post<AddByIpResponse>(`/servers/pending/${item.id}/retry`, {})
if (res.success && res.server) {
registerOnboardingJob(res.onboarding_job_id, res.onboarding_label, 1)
snackbar(`重试成功:${res.matched_preset} (${res.matched_username})`)
loadServers()
loadStats()
loadPendingServers()
} else {
showFailureDialog(res.errors || [], res.message)
loadPendingServers()
}
} catch (e: unknown) {
snackbar(formatApiError(e, '重试失败'), 'error')
} finally {
retryingId.value = null
}
}
function confirmDeletePending(item: PendingServerItem) {
deletingPending.value = item
showDeletePending.value = true
}
async function doDeletePending() {
if (!deletingPending.value) return
deletingPendingLoading.value = true
try {
await http.delete(`/servers/pending/${deletingPending.value.id}`)
showDeletePending.value = false
snackbar('已从失败列表移除')
loadPendingServers()
} catch (e: unknown) {
snackbar(formatApiError(e, '删除失败'), 'error')
} finally {
deletingPendingLoading.value = false
}
}
const {
visible: serverFormVisible,
form: serverForm,
isEditing: isEditingServer,
saving: serverFormSaving,
lookupsLoading: serverFormLookupsLoading,
passwordPresets,
sshKeyPresets,
categoryOptions,
platforms: serverFormPlatforms,
nodes: serverFormNodes,
editHints: serverFormEditHints,
filesElevationItems,
openAdd: openAddServer,
openEdit: openEditServerDialog,
close: closeServerForm,
save: saveServerForm,
} = useServerFormDialog(() => {
loadServers()
loadStats()
loadCategories()
})
watch(serverFormVisible, (open, wasOpen) => {
if (wasOpen && !open) closeServerForm()
})
// ── Batch selection (Vuetify returns IDs when item-value="id", objects when return-object) ──
const selectedItems = ref<Array<ServerApiItem | number>>([])
const selectedIds = computed(() => new Set(normalizeServerIds(selectedItems.value)))
// ── Table config ──
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'domain' },
{ title: '分类', key: 'category', width: 100 },
{ title: '目标路径', key: 'target_path', width: 200 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 200, align: 'end' as const, sortable: false },
]
const statsBooting = ref(true)
const statsRefreshing = ref(false)
// ── Stats(与仪表盘共用 composable;本页可点击筛选)──
const { statItems } = useServerStatsCards()
const unsetPathFocus = ref(false)
const statItemsDisplay = computed(() =>
statItems.value.map((item, i) => {
const key = SERVER_STAT_CARD_KEYS[i]
let active = false
if (key === 'offline') active = isOnlineFilter.value === false
else if (key === 'unset-path') active = unsetPathFocus.value
return {
...item,
key,
clickable: key !== 'total' && key !== 'online',
active,
}
}),
)
const statusFilterLabel = computed(() => {
if (isOnlineFilter.value === true) return '筛选:在线服务器'
if (isOnlineFilter.value === false) return '筛选:离线服务器'
return ''
})
function scrollToUnsetPathPanel() {
void nextTick(() => {
document.getElementById('unset-path-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function syncDashboardStatQuery() {
const status = route.query.status
if (status === 'offline') {
unsetPathFocus.value = false
if (isOnlineFilter.value !== false) {
isOnlineFilter.value = false
viewMode.value = 'table'
void loadServers()
}
} else if (status === 'online') {
unsetPathFocus.value = false
if (isOnlineFilter.value !== true) {
isOnlineFilter.value = true
viewMode.value = 'table'
void loadServers()
}
}
if (route.query.unset_path === '1') {
unsetPathFocus.value = true
scrollToUnsetPathPanel()
}
}
watch(
() => [route.query.status, route.query.unset_path] as const,
() => syncDashboardStatQuery(),
{ immediate: true },
)
function onStatCardClick(key: string) {
if (key === 'alerts') {
unsetPathFocus.value = false
router.push({ path: '/alerts' })
return
}
if (key === 'unset-path') {
unsetPathFocus.value = !unsetPathFocus.value
const q = { ...route.query }
if (unsetPathFocus.value) {
q.unset_path = '1'
scrollToUnsetPathPanel()
} else {
delete q.unset_path
}
router.replace({ path: route.path, query: q })
return
}
unsetPathFocus.value = false
if (key === 'offline') {
const next = isOnlineFilter.value === false ? null : false
onOnlineFilterChange(next)
viewMode.value = 'table'
const q = { ...route.query }
delete q.unset_path
if (next === false) q.status = 'offline'
else delete q.status
router.replace({ path: route.path, query: q })
}
}
function clearStatusFilter() {
clearOnlineFilter()
if (route.query.status) {
const q = { ...route.query }
delete q.status
router.replace({ path: route.path, query: q })
}
}
function clearUnsetPathFocus() {
unsetPathFocus.value = false
if (route.query.unset_path) {
const q = { ...route.query }
delete q.unset_path
router.replace({ path: route.path, query: q })
}
}
async function loadStats(silent = false) {
if (!silent) statsBooting.value = true
try {
const s = await fetchServerStats()
applyServerStats(statItems, s)
} catch { /* non-critical */ } finally {
if (!silent) statsBooting.value = false
}
}
async function refreshStatCards() {
statsRefreshing.value = true
try {
await refreshServerStats(statItems)
} catch {
snackbar('刷新统计数据失败', 'error')
} finally {
statsRefreshing.value = false
}
}
// ── Handlers ──
function openTerminal(item: ServerApiItem) {
router.push({ path: '/terminal', query: { server_id: String(item.id) } })
}
function openBrowser(item: ServerApiItem) {
const url = guessSiteUrlFromDomain(item.domain || '')
if (!url) {
snackbar('该服务器无有效域名', 'warning')
return
}
globalBrowser.openUrl(url, { title: item.name || url })
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
const showAgentDiagnose = ref(false)
const agentDiagnoseLoading = ref(false)
const agentDiagnoseLoadingId = ref<number | null>(null)
const agentActionLoadingId = ref<number | null>(null)
const agentDiagnoseResult = ref<AgentDiagnoseResult | null>(null)
const agentDiagnoseTarget = ref<ServerApiItem | null>(null)
async function openAgentDiagnose(item: ServerApiItem) {
agentDiagnoseTarget.value = item
agentDiagnoseResult.value = null
showAgentDiagnose.value = true
agentDiagnoseLoading.value = true
agentDiagnoseLoadingId.value = item.id
try {
agentDiagnoseResult.value = await http.post<AgentDiagnoseResult>(`/servers/${item.id}/agent-diagnose`, {})
} catch (e: unknown) {
showAgentDiagnose.value = false
snackbar(formatApiError(e, 'Agent 诊断失败'), 'error')
} finally {
agentDiagnoseLoading.value = false
agentDiagnoseLoadingId.value = null
}
}
async function installAgentOne(item: ServerApiItem) {
agentActionLoadingId.value = item.id
try {
await http.post(`/servers/${item.id}/install-agent`, {})
snackbar(`Agent 安装完成:${item.name}`, 'success')
await _load()
} catch (e: unknown) {
snackbar(formatApiError(e, '安装 Agent 失败'), 'error')
} finally {
agentActionLoadingId.value = null
}
}
async function upgradeAgentOne(item: ServerApiItem) {
agentActionLoadingId.value = item.id
try {
await submitServerBatchJob('upgrade-agent', [item.id])
snackbar(`已提交 Agent 升级:${item.name}`, 'success')
} catch (e: unknown) {
snackbar(formatApiError(e, '提交升级失败'), 'error')
} finally {
agentActionLoadingId.value = null
}
}
function editServer(item: ServerApiItem) {
void openEditServerDialog(item)
}
function confirmDelete(item: ServerApiItem) {
deletingServer.value = item
showDelete.value = true
}
// ── Batch category ──
const showBatchCategory = ref(false)
const batchCategoryValue = ref('')
const batchCategoryLoading = ref(false)
const batchCategoryOptions = computed(() => {
const names = categoryList.value
.filter(c => c.name !== 'uncategorized')
.map(c => c.label)
if (categoryOptions.value.length) {
for (const c of categoryOptions.value) {
if (c && !names.includes(c)) names.push(c)
}
}
return names.sort((a, b) => a.localeCompare(b, 'zh'))
})
function openBatchCategory(scope: BatchScope) {
batchScope.value = scope
const { ids } = batchSelection(scope)
if (ids.value.size === 0) {
snackbar('请先勾选至少一台服务器', 'error')
return
}
batchCategoryValue.value = ''
showBatchCategory.value = true
}
async function submitBatchCategory() {
const { items, ids } = batchSelection(batchScope.value)
const idList = [...ids.value]
if (idList.length === 0) return
batchCategoryLoading.value = true
try {
const category = (batchCategoryValue.value || '').trim()
await submitServerBatchJob('category', idList, { category })
showBatchCategory.value = false
items.value = []
} catch (e: unknown) {
snackbar(formatApiError(e, '批量修改分类失败'), 'error')
} finally {
batchCategoryLoading.value = false
}
}
// ── Batch operations (background queue, same as script library) ──
const showDetectPathConfirm = ref(false)
const editingPathId = ref<number | null>(null)
const editingPathValue = ref('')
const savingPathId = ref<number | null>(null)
function openDetectPathConfirm(scope: BatchScope) {
batchScope.value = scope
const { ids } = batchSelection(scope)
if (!ids.value.size) {
snackbar('请先选择服务器', 'warning')
return
}
showDetectPathConfirm.value = true
}
async function runBatchDetectPath() {
const { items, ids } = batchSelection(batchScope.value)
const idList = [...ids.value]
if (!idList.length) return
showDetectPathConfirm.value = false
try {
await submitServerBatchJob('detect-path', idList)
items.value = []
} catch (e: unknown) {
snackbar(formatApiError(e, '提交检测任务失败'), 'error')
}
}
async function batchHealthCheck(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
try {
await submitServerBatchJob('health-check', ids)
} catch (e: unknown) {
snackbar(formatApiError(e, '提交健康检查失败'), 'error')
}
}
async function batchInstallAgent(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
try {
await submitServerBatchJob('install-agent', ids)
} catch (e: unknown) {
snackbar(formatApiError(e, '提交安装任务失败'), 'error')
}
}
async function batchUpgradeAgent(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
if (!confirm(`确定对 ${ids.length} 台服务器升级 Agent`)) return
try {
await submitServerBatchJob('upgrade-agent', ids)
} catch (e: unknown) {
snackbar(formatApiError(e, '提交升级任务失败'), 'error')
}
}
async function batchUninstallAgent(scope: BatchScope) {
batchScope.value = scope
const { items } = batchSelection(scope)
const ids = normalizeServerIds(items.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
if (!confirm(
`确定对 ${ids.length} 台已安装 Agent 的服务器执行卸载?\n将停止服务、删除 /opt/nexus-agent 并清除 Agent 状态。`,
)) return
try {
await submitServerBatchJob('uninstall-agent', ids)
} catch (e: unknown) {
snackbar(formatApiError(e, '提交卸载任务失败'), 'error')
}
}
function startEditPath(item: ServerApiItem) {
editingPathId.value = item.id
editingPathValue.value = item.target_path?.trim() || '/www/wwwroot'
}
function cancelEditPath() {
editingPathId.value = null
editingPathValue.value = ''
savingPathId.value = null
}
async function saveTargetPath(serverId: number) {
const tp = editingPathValue.value.trim()
if (!tp) {
snackbar('目标路径不能为空', 'warning')
return
}
if (!tp.startsWith('/')) {
snackbar('目标路径须为绝对路径(以 / 开头)', 'warning')
return
}
savingPathId.value = serverId
try {
await http.put(`/servers/${serverId}`, { target_path: tp })
snackbar('目标路径已更新')
cancelEditPath()
await Promise.all([loadServers(), loadUnsetPathServers()])
clearExpandedForPathMigration(serverId, tp)
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
} finally {
savingPathId.value = null
}
}
async function confirmBatchDelete(scope: BatchScope) {
batchScope.value = scope
const { items, ids } = batchSelection(scope)
if (!ids.value.size) return
if (!confirm(`确定删除选中的 ${ids.value.size} 台服务器?`)) return
for (const id of ids.value) {
try { await http.delete(`/servers/${id}`) } catch { /* skip individual errors */ }
}
items.value = []
snackbar('批量删除完成')
await Promise.all([loadServers(), loadUnsetPathServers()])
loadStats()
}
// ── Batch add by IP ──
const showBatchAdd = ref(false)
const batchAddText = ref('')
const batchAddPort = ref(22)
const batchAddAgentPort = ref(8601)
const batchAddLoading = ref(false)
const batchAddResult = ref<AddByIpsBatchResult | null>(null)
function parseBatchLineDomain(line: string): string {
const trimmed = line.trim()
if (!trimmed) return ''
const tab = trimmed.indexOf('\t')
if (tab >= 0) return trimmed.slice(tab + 1).trim()
const comma = trimmed.lastIndexOf(',')
if (comma >= 0) {
const domain = trimmed.slice(comma + 1).trim()
const name = trimmed.slice(0, comma).trim()
if (domain && name && (domain.includes('.') || domain.includes(':'))) return domain
}
return trimmed
}
function parseBatchIpPreview(text: string) {
const seen = new Set<string>()
const unique: string[] = []
let raw = 0
for (const line of text.split('\n')) {
const domain = parseBatchLineDomain(line)
if (!domain) continue
raw += 1
const key = domain.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
unique.push(domain)
}
return { unique: unique.length, raw, deduped: Math.max(0, raw - unique.length) }
}
const batchAddPreview = computed(() => parseBatchIpPreview(batchAddText.value))
function openBatchAdd() {
batchAddText.value = ''
batchAddPort.value = 22
batchAddAgentPort.value = 8601
batchAddResult.value = null
showBatchAdd.value = true
}
async function submitBatchAdd() {
const text = batchAddText.value.trim()
if (!text) {
snackbar('请填写至少一个 IP 或域名', 'error')
return
}
batchAddLoading.value = true
batchAddResult.value = null
try {
const res = await http.post<AddByIpsBatchResult>('/servers/add-by-ips-batch', {
text,
port: batchAddPort.value || 22,
agent_port: batchAddAgentPort.value || 8601,
})
batchAddResult.value = res
if (res.created > 0) {
registerOnboardingJob(res.onboarding_job_id, '新服务器初始化', res.created)
loadServers()
loadStats()
}
if (res.pending > 0) {
loadPendingServers()
}
const msg = res.pending > 0
? `添加完成:${res.created} 台成功,${res.pending} 台进入连接失败列表`
: `添加完成:${res.created} 台成功`
snackbar(msg, res.pending > 0 ? 'warning' : 'success')
} catch (e: unknown) {
snackbar(formatApiError(e, '批量添加失败'), 'error')
} finally {
batchAddLoading.value = false
}
}
async function doDelete() {
if (!deletingServer.value) return
const deletedId = deletingServer.value.id
deleting.value = true
try {
await http.delete(`/servers/${deletedId}`)
showDelete.value = false
expandedMainIds.value = expandedMainIds.value.filter(id => id !== deletedId)
expandedUnsetIds.value = expandedUnsetIds.value.filter(id => id !== deletedId)
await Promise.all([loadServers(), loadUnsetPathServers()])
loadStats()
snackbar('已删除')
} catch (e: unknown) {
snackbar(formatApiError(e, '删除失败'), 'error')
} finally {
deleting.value = false
}
}
async function refreshServersPage(silent = false) {
await loadServerSearchHistory()
search.value = ''
searchDraft.value = ''
await Promise.all([
loadStats(silent),
loadCategories(silent),
loadServers(silent),
])
if (!silent) await loadPendingServers()
}
usePageActivateRefresh((silent) => refreshServersPage(silent))
</script>
<style scoped>
.server-main-table :deep(.v-data-table__td--expand-column),
.server-main-table :deep(.v-data-table-column--expand),
.server-main-table :deep(th.v-data-table__th--expand) {
display: none !important;
width: 0 !important;
padding: 0 !important;
}
.server-main-table :deep(tr.v-data-table__expanded__row > td) {
background: transparent !important;
border-bottom: none;
}
.servers-category-panels :deep(.v-expansion-panel-text__wrapper) {
background: color-mix(in srgb, rgb(var(--v-theme-surface)) 95%, rgb(var(--v-theme-on-surface)));
color: rgb(var(--v-theme-on-surface));
}
.servers-category-panels :deep(.v-table) {
background: transparent;
color: rgb(var(--v-theme-on-surface));
}
</style>