e833b923c8
接入 Playwright Worker、会话 WebSocket 与全局浏览器面板(固定于 App Bar 下); 含验证码 stealth、设置项默认音与 URL 安全校验;附带 worker 部署与设计文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
1942 lines
65 KiB
Vue
1942 lines
65 KiB
Vue
<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 192.168.1.11 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>
|