Files
Nexus/frontend/src/pages/ServersPage.vue
T
Nexus Agent fb6a4be797 feat(commands): 推送日志第三视图与 CSV 导入模板
按对齐计划 Phase1:Commands 页增加推送日志筛选视图;补全 servers_import_template.csv;扩写功能指南 §12.6/12.8/12.10 与 acceptance_catalog。
2026-06-08 02:45:17 +08:00

896 lines
33 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="statItems" />
<!-- Server Table -->
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
服务器列表
<v-spacer />
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="搜索..."
density="compact"
hide-details
rounded
style="max-width: 240px"
variant="outlined"
@update:model-value="onSearch"
/>
<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-download" size="small" @click="exportCSV">
导出CSV
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-file-import" size="small" @click="showImport = true">
导入
</v-btn>
<v-btn
variant="tonal"
class="ml-2"
prepend-icon="mdi-folder-search"
size="small"
:disabled="selectedIds.size === 0"
title="请先勾选至少一台服务器"
@click="openDetectPathConfirm"
>
检测路径
</v-btn>
</v-card-title>
<!-- Batch Actions -->
<v-card v-if="selectedIds.size > 0" class="mx-4 mb-2" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2">
<span class="text-body-2 mr-4">已选择 {{ selectedIds.size }} 台服务器</span>
<v-spacer />
<v-btn size="small" variant="text" @click="batchHealthCheck">健康检查</v-btn>
<v-btn size="small" variant="text" @click="openDetectPathConfirm">检测目标路径</v-btn>
<v-btn size="small" variant="text" @click="batchInstallAgent" :loading="batchAgentLoading">安装Agent</v-btn>
<v-btn size="small" variant="text" @click="batchUpgradeAgent" :loading="batchAgentLoading">升级Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="batchUninstallAgent" :loading="batchAgentLoading">卸载Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="confirmBatchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="selectedItems = []">取消选择</v-btn>
</v-card-text>
</v-card>
<v-data-table-server
:items="servers"
:headers="headers"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
hover
density="comfortable"
item-value="id"
show-select
v-model="selectedItems"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
@click:row="(_e: Event, { item }: { item: ServerApiItem }) => showDetail(item)"
>
<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 }">
<span class="font-weight-medium">{{ item.name }}</span>
</template>
<template #item.address="{ 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="{ item }">
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
</template>
<template #item.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">
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(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" @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 #no-data>
<div class="text-center text-medium-emphasis py-6">暂无服务器数据</div>
</template>
</v-data-table-server>
</v-card>
<!-- Pending / failed SSH connections -->
<v-card 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>
<!-- Server Detail Panel -->
<v-expand-transition>
<v-card v-if="detailServer" class="mt-4" elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-information-outline</v-icon>
{{ detailServer.name }}
<v-chip :color="statusChipColor(detailServer.status)" size="small" variant="tonal" label class="ml-2">
{{ statusLabel(detailServer.status) }}
</v-chip>
<v-spacer />
<v-btn icon="mdi-close" size="small" variant="text" @click="detailServer = null" />
</v-card-title>
<v-divider />
<v-tabs v-model="detailTab" density="compact">
<v-tab value="info">系统信息</v-tab>
<v-tab value="logs">同步日志</v-tab>
</v-tabs>
<v-tabs-window v-model="detailTab">
<v-tabs-window-item value="info">
<v-card-text>
<v-row dense>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">CPU</div>
<div class="text-h6">{{ detailServer.system_info?.cpu_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">内存</div>
<div class="text-h6">{{ detailServer.system_info?.mem_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">磁盘</div>
<div class="text-h6">{{ detailServer.system_info?.disk_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">Agent</div>
<div class="text-h6">{{ detailServer.agent_version || '未安装' }}</div>
</v-col>
<v-col cols="12" md="6">
<div class="text-caption text-medium-emphasis">目标路径</div>
<div class="text-body-1 font-weight-medium text-truncate">
{{ detailServer.target_path?.trim() || '未配置(推送页留空时无法推送到此机)' }}
</div>
</v-col>
</v-row>
</v-card-text>
</v-tabs-window-item>
<v-tabs-window-item value="logs">
<v-card-text>
<div v-if="syncLogs.length === 0" class="text-medium-emphasis text-center py-4">暂无同步日志</div>
<v-list v-else density="compact">
<v-list-item v-for="log in syncLogs" :key="log.id" :subtitle="log.created_at" :title="`${log.source_path} → ${log.target_path}`">
<template #append>
<v-chip size="x-small" :color="log.status === 'success' ? 'green' : 'red'" variant="tonal" label>{{ log.status }}</v-chip>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-tabs-window-item>
</v-tabs-window>
</v-card>
</v-expand-transition>
<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>{{ selectedIds.size }}</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>
<BatchAgentResultDialog
v-model="batchResultOpen"
:title="batchResultTitle"
:loading="batchResultLoading"
:results="batchResultItems"
/>
<!-- Import CSV Dialog -->
<v-dialog v-model="showImport" max-width="500">
<v-card border>
<v-card-title>导入服务器</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
上传 CSV 文件批量导入服务器名称地址必填),SSH端口用户名认证方式密码目标路径等可选
<a href="/app/servers_import_template.csv" download class="text-primary">下载模板</a>
<span class="text-caption text-medium-emphasis">亦可登录后访问 API <code>/api/servers/import/template</code></span>
</div>
<v-file-input v-model="importFile" label="选择 CSV 文件" variant="outlined" density="compact" accept=".csv" show-size />
<v-alert v-if="importResult" :type="importResult.failed > 0 ? 'warning' : 'success'" density="compact" variant="tonal" class="mt-3">
成功 {{ importResult.created }}跳过 {{ importResult.skipped }}失败 {{ importResult.failed }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showImport = false">关闭</v-btn>
<v-btn color="primary" variant="flat" @click="doImport" :loading="importing" :disabled="!importFile?.length">开始导入</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import type { AddByIpResponse, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds, type BatchAgentResultItem } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import StatCardsRow from '@/components/StatCardsRow.vue'
import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
import BatchAgentResultDialog from '@/components/servers/BatchAgentResultDialog.vue'
import { useServerFormDialog } from '@/composables/servers/useServerFormDialog'
const snackbar = useSnackbar()
const router = useRouter()
// ── Paginated server list (composable) ──
const {
servers, loading, page, itemsPerPage, total, search,
loadServers: _load, onSearch, onPageChange, onItemsPerPageChange,
} = useServerPagination()
async function loadServers() {
try {
await _load()
} catch {
snackbar('加载服务器列表失败', 'error')
}
}
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() {
pendingLoading.value = true
try {
const res = await http.get<{ items: PendingServerItem[]; total: number }>('/servers/pending')
pendingServers.value = res.items || []
} catch (e: unknown) {
pendingServers.value = []
snackbar(formatApiError(e, '加载失败列表失败'), 'error')
} finally {
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
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) {
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()
})
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)))
// ── Detail panel ──
const detailServer = ref<ServerApiItem | null>(null)
const detailTab = ref('info')
const syncLogs = ref<{ id: number; source_path: string; target_path: string; status: string; created_at: string }[]>([])
// ── Table config ──
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'address' },
{ title: '分类', key: 'category', width: 100 },
{ title: '目标路径', key: 'target_path', width: 200 },
{ title: 'Agent', key: 'agent', width: 100 },
{ title: '心跳', key: 'heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
]
// ── Stats(与仪表盘 StatCardsRow 一致)──
const statItems = ref([
{ subtitle: '服务器总数', title: '0', icon: 'mdi-server-network', color: 'blue' },
{ subtitle: '在线', title: '0', icon: 'mdi-check-circle-outline', color: 'green' },
{ subtitle: '离线', title: '0', icon: 'mdi-alert-circle-outline', color: 'orange' },
{ subtitle: '告警中', title: '0', icon: 'mdi-bell-alert-outline', color: 'red' },
])
async function loadStats() {
try {
const s = await http.get<{ total: number; online: number; offline: number; alerts: number }>('/servers/stats')
statItems.value[0].title = String(s.total)
statItems.value[1].title = String(s.online)
statItems.value[2].title = String(s.offline)
statItems.value[3].title = String(s.alerts ?? 0)
} catch { /* non-critical */ }
}
// ── Handlers ──
function openTerminal(item: ServerApiItem) {
router.push({ path: '/terminal', query: { server_id: String(item.id) } })
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
// ── Batch operations ──
const batchAgentLoading = ref(false)
const showDetectPathConfirm = ref(false)
const batchResultOpen = ref(false)
const batchResultLoading = ref(false)
const batchResultTitle = ref('')
const batchResultItems = ref<BatchAgentResultItem[]>([])
const editingPathId = ref<number | null>(null)
const editingPathValue = ref('')
const savingPathId = ref<number | null>(null)
function openBatchResultDialog(title: string) {
batchResultTitle.value = title
batchResultItems.value = []
batchResultLoading.value = true
batchResultOpen.value = true
}
function finishBatchResultDialog(results: BatchAgentResultItem[]) {
batchResultItems.value = results
batchResultLoading.value = false
}
async function runBatchAgentOp(
title: string,
path: string,
opts?: { confirm?: string; reload?: boolean },
) {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) {
snackbar('请先选择服务器', 'warning')
return
}
if (opts?.confirm && !confirm(opts.confirm.replace('{n}', String(ids.length)))) return
openBatchResultDialog(title)
try {
const res = await http.post<{ results: BatchAgentResultItem[] }>(path, { server_ids: ids })
finishBatchResultDialog(res.results || [])
if (opts?.reload !== false) loadServers()
} catch (e: unknown) {
batchResultOpen.value = false
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()
loadServers()
if (detailServer.value?.id === serverId) {
detailServer.value = { ...detailServer.value, target_path: tp }
}
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
} finally {
savingPathId.value = null
}
}
function openDetectPathConfirm() {
if (!selectedIds.value.size) {
snackbar('请先选择服务器', 'warning')
return
}
showDetectPathConfirm.value = true
}
async function runBatchDetectPath() {
showDetectPathConfirm.value = false
await runBatchAgentOp('自动检测目标路径', '/servers/batch/detect-path')
}
async function batchHealthCheck() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) return
snackbar(`正在检查 ${ids.length} 台服务器...`)
try {
await http.post('/servers/check', { server_ids: ids })
snackbar('健康检查完成')
loadServers()
} catch (e: unknown) { snackbar(formatApiError(e, '检查失败'), 'error') }
}
async function confirmBatchDelete() {
if (!confirm(`确定删除选中的 ${selectedIds.value.size} 台服务器?`)) return
for (const id of selectedIds.value) {
try { await http.delete(`/servers/${id}`) } catch { /* skip individual errors */ }
}
selectedItems.value = []
snackbar('批量删除完成')
loadServers()
loadStats()
}
async function batchInstallAgent() {
batchAgentLoading.value = true
try {
await runBatchAgentOp('批量安装 Agent', '/servers/batch/install-agent', { reload: true })
} finally {
batchAgentLoading.value = false
}
}
async function batchUpgradeAgent() {
batchAgentLoading.value = true
try {
await runBatchAgentOp(
'批量升级 Agent',
'/servers/batch/upgrade-agent',
{ confirm: '确定对 {n} 台服务器升级 Agent', reload: true },
)
} finally {
batchAgentLoading.value = false
}
}
async function batchUninstallAgent() {
batchAgentLoading.value = true
try {
await runBatchAgentOp(
'批量卸载 Agent',
'/servers/batch/uninstall-agent',
{
confirm:
'确定对 {n} 台已安装 Agent 的服务器执行卸载?\n将停止服务、删除 /opt/nexus-agent 并清除 Agent 状态。',
reload: true,
},
)
} finally {
batchAgentLoading.value = false
}
}
// ── CSV export ──
async function exportCSV() {
try {
const res = await http.getList<ServerApiItem>('/servers/', { per_page: 5000 })
const headers = ['ID', '名称', '地址', '端口', '用户名', '分类', '状态', 'Agent版本', '最后心跳']
const rows = (res.items || []).map(s => [
s.id, s.name, s.domain, s.port, s.username, s.category || '', s.status,
s.agent_version || '', s.last_heartbeat || '',
])
const bom = ''
const csv = bom + [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
snackbar('导出完成')
} catch (e: any) { snackbar(e.message || '导出失败', 'error') }
}
// ── CSV import ──
const showImport = ref(false)
const importFile = ref<File[]>([])
const importing = ref(false)
const importResult = ref<{ created: number; skipped: number; failed: number } | null>(null)
async function doImport() {
if (!importFile.value?.length) return
importing.value = true
importResult.value = null
try {
const form = new FormData()
form.append('file', importFile.value[0])
const res = await http.upload<{ created: number; skipped: number; failed: number }>('/servers/import', form)
importResult.value = res
snackbar('导入完成')
loadServers()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '导入失败'
snackbar(msg, 'error')
} finally {
importing.value = false
}
}
// ── Server detail panel ──
async function showDetail(item: ServerApiItem) {
detailServer.value = item
detailTab.value = 'info'
try {
const res = await http.get<{ items: typeof syncLogs.value }>(`/servers/${item.id}/logs`, { limit: 20 })
syncLogs.value = res.items || []
} catch { syncLogs.value = [] }
}
function editServer(item: ServerApiItem) {
void openEditServerDialog(item)
}
function confirmDelete(item: ServerApiItem) {
deletingServer.value = item
showDelete.value = true
}
async function doDelete() {
if (!deletingServer.value) return
deleting.value = true
try {
await http.delete(`/servers/${deletingServer.value.id}`)
showDelete.value = false
loadServers()
loadStats()
snackbar('已删除')
} catch (e: any) {
snackbar(e.message || '删除失败', 'error')
} finally {
deleting.value = false
}
}
// ── Init ──
onMounted(() => {
loadStats()
loadServers()
loadPendingServers()
})
</script>