fb6a4be797
按对齐计划 Phase1:Commands 页增加推送日志筛选视图;补全 servers_import_template.csv;扩写功能指南 §12.6/12.8/12.10 与 acceptance_catalog。
896 lines
33 KiB
Vue
896 lines
33 KiB
Vue
<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>
|