feat(servers): 分类筛选/批量改分类、分页中文与列排序,脚本库按分类选机

支持 300+ 台服务器按分类浏览与批量管理,修复分页「全部」与排序;门控 Gate3 强制本地 API。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-08 04:30:24 +08:00
parent c8b0663508
commit c435413563
30 changed files with 1419 additions and 142 deletions
+2 -1
View File
@@ -126,7 +126,8 @@ if [ ! -f "${TEST_SCRIPT}" ]; then
BLOCKED=1
gate_log "test" "BLOCK" "script not found"
else
TEST_OUTPUT=$(cd "${DEPLOY_DIR}" && python3 "${TEST_SCRIPT}" 2>&1) || true
# Always hit local API for gate 3 (ignore shell NEXUS_TEST_BASE used by import/e2e scripts)
TEST_OUTPUT=$(cd "${DEPLOY_DIR}" && env -u NEXUS_TEST_BASE NEXUS_TEST_BASE=http://127.0.0.1:8600 python3 "${TEST_SCRIPT}" 2>&1) || true
FAIL_COUNT=$(echo "${TEST_OUTPUT}" | grep -cE "^\s+\[FAIL\]" 2>/dev/null || true)
FAIL_COUNT=${FAIL_COUNT:-0}
if [ "${FAIL_COUNT}" -gt 0 ] || echo "${TEST_OUTPUT}" | grep -qE "[0-9]+ test(s) failed"; then
@@ -0,0 +1,120 @@
# 审计 — 2026-06-08 服务器分类 / 分页 / 排序 / 脚本库选机
## 审计信息
- **日期**: 2026-06-08
- **审计人**: Cursor Agent
- **触发原因**: 新功能(分类筛选、批量改分类、脚本库选机、分页与排序修复)生产部署
## 审计范围
本批次含 2026-06-07 已提交对齐项(c8b0663)与 2026-06-08 分类/分页/排序增量。
| 文件 | 状态 |
|------|------|
| `Dockerfile.prod` | ☑ 已审(CSV 模板 COPY |
| `deploy/pre_deploy_check.sh` | ☑ 已审(Gate3 强制本地 BASE |
| `deploy/gate_log.jsonl` | ☑ 自动生成 |
| `server/api/servers.py` | ☑ 已审 |
| `server/api/schemas.py` | ☑ 已审 |
| `server/infrastructure/database/server_repo.py` | ☑ 已审 |
| `server/infrastructure/database/migrations.py` | ☑ 已审 |
| `server/domain/models/__init__.py` | ☑ 已审 |
| `server/application/services/credential_poller.py` | ☑ 已审 |
| `server/application/services/script_service.py` | ☑ 已审 |
| `server/application/services/sync_engine_v2.py` | ☑ 已审 |
| `frontend/public/servers_import_template.csv` | ☑ 已审 |
| `frontend/src/pages/ServersPage.vue` | ☑ 已审 |
| `frontend/src/pages/ScriptsPage.vue` | ☑ 已审 |
| `frontend/src/pages/CommandsPage.vue` | ☑ 已审 |
| `frontend/src/pages/DashboardPage.vue` | ☑ 已审 |
| `frontend/src/pages/AlertsPage.vue` | ☑ 已审 |
| `frontend/src/pages/AuditPage.vue` | ☑ 已审 |
| `frontend/src/pages/CredentialsPage.vue` | ☑ 已审 |
| `frontend/src/pages/RetriesPage.vue` | ☑ 已审 |
| `frontend/src/pages/SchedulesPage.vue` | ☑ 已审 |
| `frontend/src/components/push/PushHistoryTable.vue` | ☑ 已审 |
| `frontend/src/components/servers/ServerCategoryPicker.vue` | ☑ 已审 |
| `frontend/src/composables/useServerPagination.ts` | ☑ 已审 |
| `frontend/src/composables/useServerList.ts` | ☑ 已审 |
| `frontend/src/composables/useServerCategories.ts` | ☑ 已审 |
| `frontend/src/composables/push/usePushLogs.ts` | ☑ 已审 |
| `frontend/src/composables/push/labels.ts` | ☑ 已审 |
| `frontend/src/composables/files/constants.ts` | ☑ 已审 |
| `frontend/src/constants/dataTable.ts` | ☑ 已审 |
| `frontend/src/utils/paginatedFetch.ts` | ☑ 已审 |
| `frontend/src/utils/serverTableSort.ts` | ☑ 已审 |
| `frontend/src/plugins/vuetify.ts` | ☑ 已审 |
| `frontend/src/types/api.ts` | ☑ 已审 |
| `scripts/import_servers_from_xls.py` | ☑ 已审 |
| `tests/test_credential_poller.py` | ☑ 已审 |
| `tests/test_rsync_push_permissions.py` | ☑ 已审 |
| `tests/test_script_dispatch.py` | ☑ 已审 |
| `web/app/index.html` | ☑ 构建产物入口 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 批量改分类须鉴权 + 审计 | PASS `Depends(get_current_admin)` + `audit_log batch_update_category` |
| H2 | 单次批量上限防滥用 | PASS schema `max_length=200`repo 分块 |
| H3 | 分类筛选 SQL 注入 | PASS 参数化 `category = :cat` / `IS NULL` |
| H4 | 前端「全部」不得单页截断 | PASS `paginatedFetch` 多页 200 上限循环 |
| H5 | 排序字段白名单 | PASS `server_repo` 仅允许已知列 |
| H6 | 导入脚本无明文密钥入仓 | PASS 读环境变量;凭据不入 git |
| H7 | CUD 无静默吞错 | PASS API 显式 HTTPExceptionpoller 记录失败 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | `servers.py` `batch_update_category` + audit |
| H2 | PASS | `BatchCategoryUpdate.server_ids` Field(max_length=200) |
| H3 | PASS | `server_repo.list_servers` 绑定参数 |
| H4 | PASS | `fetchPagePerPage` while 循环至 `total` |
| H5 | PASS | `_SORT_COLUMNS` 映射 |
| H6 | PASS | `import_servers_from_xls.py` os.environ |
| H7 | PASS | 无 bare `except: pass` |
## Step 5 入口表
| 入口 | 鉴权 | 说明 |
|------|------|------|
| `GET /api/servers` | JWT | `category`/`sort_by`/`sort_order` 查询 |
| `POST /api/servers/batch/category` | JWT + admin | 批量改分类 |
| `POST /api/servers/batch` | JWT + admin | 名称+IP 批量添加 |
| `#/servers` UI | 会话 | 筛选/排序/批量 |
| `#/scripts` UI | 会话 | 分类选机 |
| `scripts/import_servers_from_xls.py` | CLI + JWT | 运维导入 |
## Step 6 输入→Sink
- `category` 查询 → ORM `WHERE category = :cat`(参数化)
- `server_ids[]``UPDATE servers SET category`IN 绑定)
- Excel IP/名称 → `add-by-ip` API(后端校验 IP 格式)
- 排序列 key → 白名单映射 → ORDER BY
## Step 7 归类
```
servers.py + schemas + server_repo 3文件 7H 0FINDING
frontend 页面/composables/utils 8文件 7H 0FINDING
import_servers_from_xls.py 1文件 2H 0FINDING
────────────────────────────────────────────────────
总计 7H 0FINDING
```
## Step 8 DoD
- ☑ 功能与 changelog 一致
-`pytest tests/test_credential_poller.py` 通过
- ☑ 门控 lint/import/security 通过
- ☑ 0 FINDING
## FINDING 列表
## 结论
☑ 审计通过,0 FINDING,可部署
@@ -0,0 +1,52 @@
# 2026-06-07 — 服务器分类筛选 / 批量改分类 / 脚本库按分类选机
## 日期
2026-06-07
## 变更摘要
服务器列表新增按分类筛选、分类分组视图、批量改分类;脚本库执行/临时命令的服务器选择改为按分类勾选(与推送页一致)。
## 动机
导入 300+ 台服务器后需按业务分类浏览与管理;原列表仅有分类列,无法筛选或批量调整。
## 涉及文件
| 文件 | 说明 |
|------|------|
| `server/infrastructure/database/server_repo.py` | `uncategorized` 筛选;`update_category_batch()` |
| `server/api/servers.py` | `POST /api/servers/batch/category` |
| `server/api/schemas.py` | `BatchCategoryUpdate` / `BatchCategoryResult` |
| `frontend/src/composables/useServerCategories.ts` | 分类列表与计数 |
| `frontend/src/composables/useServerPagination.ts` | `categoryFilter``loadAllServers()` |
| `frontend/src/pages/ServersPage.vue` | 分类 Chip、列表/分类视图、批量改分类对话框 |
| `frontend/src/types/api.ts` | `BatchCategoryResult` 类型 |
| `frontend/src/components/servers/ServerCategoryPicker.vue` | 可复用按分类服务器选择器 |
| `frontend/src/composables/useServerList.ts` | `category` 字段;`loadServers({ all: true })` 分页拉全量 |
| `frontend/src/pages/ScriptsPage.vue` | 执行对话框与临时命令改用 `ServerCategoryPicker` |
## 功能说明
- **分类筛选**:Chip 展示各分类数量,点击过滤列表(`uncategorized` → 未分类)。
- **分类视图**:切换「分类」后以折叠面板按分类分组展示,支持勾选与批量操作。
- **批量改分类**:勾选后点「修改分类」,支持选择已有分类或输入新名称;留空清除分类。单次 API 最多 200 台,前端自动分块。
- **脚本库选机**`#/scripts` 执行脚本对话框与「临时命令执行」支持按分类全选/单选、搜索过滤,展示在线状态与已选台数。
## 是否需迁移
## 是否需重启
是(后端 API 变更);前端需 `vite build` 后部署
## 2026-06-07 补充:服务器列表列排序
- `useServerPagination` 接入 `v-model:sort-by` → API `sort_by` / `sort_order`
- 表头 key 与后端对齐(`domain``agent_version``last_heartbeat`);`操作``sortable: false`
- 后端新增 `target_path` 可排序字段
- 未实现排序的其它 `v-data-table-server` 默认关闭排序箭头(`headersWithoutSort`
## 2026-06-07 补充:表格分页中文化与「全部」修复
- Vuetify 全局 `zhHans` 语言包(「每页数目」「全部」「上一页」等)
- `fetchPagePerPage` / `fetchLimitOffset`:选「全部」(-1) 时自动多页拉取(API 单次上限 200)
- 修复调度列表 `slice(0,-1)` 误删最后一行;命令日志页补 `items-per-page` 变更回调
## 验证方式
1. `pytest tests/test_credential_poller.py`(既有用例)
2. 本地 `#/servers`:分类 Chip 筛选、分类视图、批量改分类
3. `POST /api/servers/batch/category` 审计日志 `batch_update_category`
@@ -0,0 +1,43 @@
# 2026-06-08 — 服务器分类/分页/排序 + 脚本库选机(生产部署)
## 日期
2026-06-08
## 变更摘要
服务器列表:按分类筛选与分组视图、批量改分类、列排序修复、Vuetify 分页中文化与「全部」多页拉取;脚本库执行/临时命令改为按分类选机;新增 Excel 批量导入脚本。
## 动机
生产已导入 300+ 台服务器,需按业务分类浏览、批量调整分类,并在脚本库按分类勾选目标机;原分页「全部」无效、排序箭头无响应、分页文案为英文。
## 涉及文件
| 文件 | 说明 |
|------|------|
| `server/infrastructure/database/server_repo.py` | `uncategorized` 筛选;`update_category_batch()``target_path` 可排序 |
| `server/api/servers.py` | `POST /api/servers/batch/category` |
| `server/api/schemas.py` | `BatchCategoryUpdate` / `BatchCategoryResult` |
| `server/application/services/credential_poller.py` | 批量添加支持 `名称<TAB>IP` |
| `frontend/src/pages/ServersPage.vue` | 分类 Chip、列表/分类视图、批量改分类、排序 |
| `frontend/src/pages/ScriptsPage.vue` | `ServerCategoryPicker` 选机 |
| `frontend/src/composables/useServerCategories.ts` | 分类列表与计数 |
| `frontend/src/composables/useServerPagination.ts` | 分类筛选、`sort_by`/`sort_order` |
| `frontend/src/composables/useServerList.ts` | `loadServers({ all: true })` |
| `frontend/src/components/servers/ServerCategoryPicker.vue` | 可复用按分类选择器 |
| `frontend/src/constants/dataTable.ts` | 分页选项含「全部」(-1) |
| `frontend/src/utils/paginatedFetch.ts` | `-1` 时多页拉取 |
| `frontend/src/utils/serverTableSort.ts` | 列 key → API 字段映射 |
| `frontend/src/plugins/vuetify.ts` | 全局 `zhHans` |
| `scripts/import_servers_from_xls.py` | Excel 批量导入(add-by-ip |
| `tests/test_credential_poller.py` | 批量名称解析用例 |
## 是否需迁移
## 是否需重启
是(后端 API);前端需 `vite build` 后随 Docker 镜像部署
## 验证方式
1. `pytest tests/test_credential_poller.py`
2. `bash deploy/pre_deploy_check.sh`Gate 17
3. 生产 `#/servers`:分类筛选、分组、批量改分类、列排序、分页「全部」
4. 生产 `#/scripts`:按分类选机执行脚本
5. `curl https://api.synaglobal.vip/health``ok``/app/` → 200
@@ -0,0 +1,178 @@
<template>
<div class="server-category-picker">
<div class="d-flex align-center flex-wrap ga-2 mb-2">
<v-checkbox
:model-value="selectAll"
label="全选"
density="compact"
hide-details
@update:model-value="toggleAll"
/>
<v-text-field
v-model="serverSearch"
prepend-inner-icon="mdi-magnify"
label="搜索服务器"
density="compact"
hide-details
variant="outlined"
rounded
style="max-width: 220px"
clearable
/>
<v-chip v-if="model.length > 0" size="small" color="primary" variant="tonal" label>
已选 {{ model.length }}
</v-chip>
<v-progress-circular v-if="loading" indeterminate size="18" width="2" />
</div>
<div class="picker-scroll" :style="{ maxHeight }">
<div v-if="!loading && serversByCategory.length === 0" class="text-center text-medium-emphasis py-4">
暂无服务器
</div>
<div v-for="group in serversByCategory" :key="group.category" class="mb-3">
<div class="d-flex align-center px-1 mb-1">
<v-btn
size="x-small"
variant="text"
:color="isCategoryAllSelected(group.category) ? 'primary' : 'default'"
@click="toggleCategory(group.category)"
>
<v-icon size="16" class="mr-1">
{{ isCategoryAllSelected(group.category) ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline' }}
</v-icon>
{{ group.label }}
</v-btn>
<span class="text-caption text-medium-emphasis ml-1">
({{ groupSelectedCount(group) }}/{{ group.servers.length }})
</span>
</div>
<v-row dense>
<v-col v-for="s in group.servers" :key="s.id" cols="12" sm="6">
<v-card
:color="selectedSet.has(s.id) ? 'primary' : undefined"
:variant="selectedSet.has(s.id) ? 'tonal' : 'outlined'"
rounded="lg"
class="pa-2 cursor-pointer"
@click="toggleServer(s.id)"
>
<div class="d-flex align-center ga-2">
<v-icon :color="serverOnlineColor(s.is_online)" size="10">mdi-circle</v-icon>
<span class="text-body-2 font-weight-medium text-truncate">{{ s.name }}</span>
</div>
<div class="text-caption text-medium-emphasis text-truncate">{{ s.domain }}</div>
</v-card>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { categoryDisplayLabel } from '@/composables/useServerCategories'
import { serverOnlineColor } from '@/composables/push/labels'
import type { ServerBrief } from '@/composables/useServerList'
const model = defineModel<number[]>({ default: () => [] })
const props = withDefaults(
defineProps<{
servers: ServerBrief[]
loading?: boolean
maxHeight?: string
}>(),
{ loading: false, maxHeight: '280px' },
)
const serverSearch = ref('')
const selectedSet = computed(() => new Set(model.value))
const filteredServers = computed(() => {
const q = serverSearch.value.trim().toLowerCase()
if (!q) return props.servers
return props.servers.filter(
s =>
s.name.toLowerCase().includes(q) ||
(s.domain || '').toLowerCase().includes(q) ||
(s.category || '').toLowerCase().includes(q),
)
})
const serversByCategory = computed(() => {
const groups: Record<string, ServerBrief[]> = {}
for (const s of filteredServers.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) => a.name.localeCompare(b.name, 'zh')),
}))
.sort((a, b) => {
if (a.category === 'uncategorized') return 1
if (b.category === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
})
const selectAll = computed(() => {
const visible = filteredServers.value
return visible.length > 0 && visible.every(s => selectedSet.value.has(s.id))
})
function groupSelectedCount(group: { servers: ServerBrief[] }): number {
return group.servers.filter(s => selectedSet.value.has(s.id)).length
}
function isCategoryAllSelected(category: string): boolean {
const group = serversByCategory.value.find(g => g.category === category)
if (!group || group.servers.length === 0) return false
return group.servers.every(s => selectedSet.value.has(s.id))
}
function emitIds(next: Set<number>) {
model.value = [...next]
}
function toggleServer(id: number) {
const next = new Set(model.value)
if (next.has(id)) next.delete(id)
else next.add(id)
emitIds(next)
}
function toggleCategory(category: string) {
const group = serversByCategory.value.find(g => g.category === category)
if (!group) return
const next = new Set(model.value)
const allSelected = group.servers.every(s => next.has(s.id))
for (const s of group.servers) {
if (allSelected) next.delete(s.id)
else next.add(s.id)
}
emitIds(next)
}
function toggleAll() {
const visible = filteredServers.value
const next = new Set(model.value)
const allSelected = visible.length > 0 && visible.every(s => next.has(s.id))
for (const s of visible) {
if (allSelected) next.delete(s.id)
else next.add(s.id)
}
emitIds(next)
}
</script>
<style scoped>
.picker-scroll {
overflow-y: auto;
padding-right: 4px;
}
</style>
+9 -1
View File
@@ -1,9 +1,17 @@
export const FILES_LAST_SERVER_KEY = 'nexus:files:last_server_id'
export const FILES_HINT_DISMISSED_KEY = 'nexus:files-hint-dismissed'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
/** v-data-table 默认每页条数(Vuetify 默认 10 过少) */
export const FILES_TABLE_ITEMS_PER_PAGE = 50
export const FILES_TABLE_ITEMS_PER_PAGE_OPTIONS = [25, 50, 100, 250, -1] as const
export const FILES_TABLE_ITEMS_PER_PAGE_OPTIONS = [
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
{ value: 250, title: '250' },
...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS.filter(o => o.value === -1),
] as const
export const FILES_CACHE_TTL_MS = 5000
export const FILES_CACHE_MAX_ENTRIES = 50
@@ -0,0 +1,48 @@
import { ref } from 'vue'
import { http } from '@/api'
export interface ServerCategoryOption {
/** API value: `` for all, ``uncategorized`` for empty category */
name: string
label: string
count: number
}
export function categoryDisplayLabel(name: string): string {
return name === 'uncategorized' ? '未分类' : name
}
export function useServerCategories() {
const categories = ref<ServerCategoryOption[]>([])
const loading = ref(false)
const totalCount = ref(0)
async function loadCategories() {
loading.value = true
try {
const res = await http.get<{
categories: { name: string; count: number }[]
}>('/servers/categories')
const items = res.categories || []
totalCount.value = items.reduce((sum, c) => sum + (c.count || 0), 0)
categories.value = items
.map(c => ({
name: c.name,
label: categoryDisplayLabel(c.name),
count: c.count,
}))
.sort((a, b) => {
if (a.name === 'uncategorized') return 1
if (b.name === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
} catch {
categories.value = []
totalCount.value = 0
} finally {
loading.value = false
}
}
return { categories, loading, totalCount, loadCategories }
}
+22 -5
View File
@@ -8,12 +8,14 @@ import { ref } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import type { PaginatedResponse } from '@/types/api'
export interface ServerBrief {
id: number
name: string
domain?: string
target_path?: string
category?: string | null
is_online?: boolean
status?: string
}
@@ -23,13 +25,28 @@ export function useServerList() {
const loading = ref(false)
const snackbar = useSnackbar()
async function loadServers(opts?: { perPage?: number }) {
async function loadServers(opts?: { perPage?: number; all?: boolean }) {
loading.value = true
try {
const res = await http.getList<ServerBrief>('/servers/', {
per_page: opts?.perPage ?? 200,
})
servers.value = res.items
const perPage = opts?.perPage ?? 200
if (!opts?.all) {
const res = await http.getList<ServerBrief>('/servers/', { per_page: perPage })
servers.value = res.items
return
}
const all: ServerBrief[] = []
let page = 1
let pages = 1
do {
const res = await http.get<PaginatedResponse<ServerBrief>>('/servers/', {
page,
per_page: perPage,
})
all.push(...(res.items || []))
pages = res.pages ?? 1
page += 1
} while (page <= pages)
servers.value = all
} catch (e: unknown) {
servers.value = []
snackbar(formatApiError(e, '加载服务器列表失败'), 'error')
+49 -11
View File
@@ -5,9 +5,9 @@
* Eliminates the identical pattern in ServersPage and DashboardPage.
*/
import { ref, onUnmounted } from 'vue'
import { http } from '@/api'
import type { ServerApiItem } from '@/types/api'
import type { PaginatedResponse } from '@/types/api'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { toServerApiSort, type TableSortItem } from '@/utils/serverTableSort'
export function useServerPagination() {
const servers = ref<ServerApiItem[]>([])
@@ -16,17 +16,28 @@ export function useServerPagination() {
const itemsPerPage = ref(20)
const total = ref(0)
const search = ref('')
/** API category filter; empty string = all */
const categoryFilter = ref('')
const sortBy = ref<TableSortItem[]>([])
async function loadServers() {
loading.value = true
try {
const res = await http.get<PaginatedResponse<ServerApiItem>>('/servers/', {
page: page.value,
per_page: itemsPerPage.value,
search: search.value || undefined,
})
servers.value = res.items || []
total.value = res.total || 0
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
{
search: search.value || undefined,
category: categoryFilter.value || undefined,
...toServerApiSort(sortBy.value),
},
page.value,
itemsPerPage.value,
)
servers.value = res.items
total.value = res.total
if (itemsPerPage.value === -1) {
page.value = 1
}
} catch (e) {
servers.value = []
total.value = 0
@@ -54,8 +65,35 @@ export function useServerPagination() {
loadServers()
}
function onCategoryChange(category: string) {
categoryFilter.value = category
page.value = 1
loadServers()
}
function onSortByChange(next: TableSortItem[]) {
sortBy.value = next
page.value = 1
loadServers()
}
async function loadAllServers(): Promise<ServerApiItem[]> {
const res = await fetchPagePerPage<ServerApiItem>(
'/servers/',
{
search: search.value || undefined,
category: categoryFilter.value || undefined,
...toServerApiSort(sortBy.value),
},
1,
-1,
)
return res.items
}
return {
servers, loading, page, itemsPerPage, total, search,
loadServers, onSearch, onPageChange, onItemsPerPageChange,
servers, loading, page, itemsPerPage, total, search, categoryFilter, sortBy,
loadServers, onSearch, onPageChange, onItemsPerPageChange, onCategoryChange, onSortByChange,
loadAllServers,
}
}
+29
View File
@@ -0,0 +1,29 @@
/** Vuetify v-data-table 每页条数(-1 = 全部,title 已中文化) */
export const DATA_TABLE_ITEMS_PER_PAGE_OPTIONS = [
{ value: 10, title: '10' },
{ value: 20, title: '20' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
{ value: 200, title: '200' },
{ value: -1, title: '全部' },
] as const
export type DataTableHeader = {
title: string
key: string
width?: number
align?: 'start' | 'center' | 'end'
sortable?: boolean
}
/** 未接服务端排序的表格:默认关闭列头排序箭头,避免点击无效果 */
export function headersWithoutSort(headers: DataTableHeader[]): DataTableHeader[] {
return headers.map(h => ({ ...h, sortable: h.sortable ?? false }))
}
/** 客户端分页切片(-1 表示不分页) */
export function sliceClientPage<T>(items: T[], page: number, itemsPerPage: number): T[] {
if (itemsPerPage === -1) return items
const start = (page - 1) * itemsPerPage
return items.slice(start, start + itemsPerPage)
}
+3 -2
View File
@@ -98,6 +98,7 @@ import { ref, onMounted } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AlertLogItem, AlertStatsResponse } from '@/types/api'
import { headersWithoutSort } from '@/constants/dataTable'
const snackbar = useSnackbar()
@@ -128,13 +129,13 @@ const statusOptions = [
{ label: '已恢复', value: 'recovered' },
]
const headers = [
const headers = headersWithoutSort([
{ title: '类型', key: 'alert_type', width: 100 },
{ title: '服务器', key: 'server_name' },
{ title: '详情', key: 'value' },
{ title: '状态', key: 'is_recovery', width: 100 },
{ title: '时间', key: 'created_at', width: 160 },
]
])
// ── Data loading ──
async function loadStats() {
+3 -2
View File
@@ -99,6 +99,7 @@ import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AuditItem } from '@/types/api'
import { auditActionLabel, auditTargetLabel, auditActionFilterOptions } from '@/utils/auditLabels'
import { normalizeAuditList } from '@/utils/auditNormalize'
import { headersWithoutSort } from '@/constants/dataTable'
const snackbar = useSnackbar()
@@ -113,14 +114,14 @@ const dateFilter = ref('')
const actionOptions = auditActionFilterOptions
const headers = [
const headers = headersWithoutSort([
{ title: '操作', key: 'action', width: 100 },
{ title: '用户', key: 'admin_username', width: 100 },
{ title: '目标', key: 'target', width: 140 },
{ title: '详情', key: 'detail' },
{ title: 'IP', key: 'ip_address', width: 120 },
{ title: '时间', key: 'created_at', width: 160 },
]
])
// ── Data loading ──
async function loadAudit() {
+40 -23
View File
@@ -83,9 +83,11 @@
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="compact"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.command="{ item }">
<code class="text-body-2">{{ item.command }}</code>
@@ -113,9 +115,11 @@
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="compact"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.server_name="{ item }">
<span class="font-weight-medium">{{ item.server_name || '—' }}</span>
@@ -142,9 +146,11 @@
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="compact"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.started_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.started_at || '—' }}</span>
@@ -205,9 +211,12 @@ import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { formatPushPermission } from '@/composables/push/labels'
import type { CommandLogItem, PushItem, SshSessionItem } from '@/types/api'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
type ViewMode = 'commands' | 'sessions' | 'push'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const { servers: serverList, loadServers } = useServerList()
@@ -253,21 +262,21 @@ const commands = ref<CommandLogItem[]>([])
const sessions = ref<SshSessionItem[]>([])
const pushLogs = ref<PushItem[]>([])
const commandHeaders = [
const commandHeaders = headersWithoutSort([
{ title: '命令', key: 'command' },
{ title: '服务器', key: 'server_name' },
{ title: '用户', key: 'admin_username', width: 100 },
{ title: '时间', key: 'created_at', width: 160 },
]
])
const sessionHeaders = [
const sessionHeaders = headersWithoutSort([
{ title: '服务器', key: 'server_name' },
{ title: '状态', key: 'status', width: 80 },
{ title: '来源', key: 'remote_addr', width: 120 },
{ title: '开始时间', key: 'started_at', width: 160 },
]
])
const pushHeaders = [
const pushHeaders = headersWithoutSort([
{ title: '时间', key: 'started_at', width: 160 },
{ title: '服务器', key: 'server_name', width: 120 },
{ title: '状态', key: 'status', width: 88 },
@@ -277,7 +286,7 @@ const pushHeaders = [
{ title: '目标路径', key: 'target_path' },
{ title: '权限', key: 'push_permission', width: 130 },
{ title: '错误', key: 'error_message', width: 180 },
]
])
function pushStatusColor(status: string): string {
switch (status) {
@@ -321,37 +330,45 @@ function onPageChange(p: number) {
loadData()
}
function onItemsPerPageChange(n: number) {
itemsPerPage.value = n
page.value = 1
loadData()
}
async function loadData() {
loading.value = true
try {
if (viewMode.value === 'commands') {
const res = await http.getList<CommandLogItem>('/assets/command-logs', {
server_id: serverFilter.value || undefined,
page: page.value,
per_page: itemsPerPage.value,
})
const res = await fetchPagePerPage<CommandLogItem>(
'/assets/command-logs',
{ server_id: serverFilter.value || undefined },
page.value,
itemsPerPage.value,
)
commands.value = res.items
total.value = res.total
} else if (viewMode.value === 'sessions') {
const res = await http.getList<SshSessionItem>('/assets/ssh-sessions', {
server_id: serverFilter.value || undefined,
page: page.value,
per_page: itemsPerPage.value,
})
const res = await fetchPagePerPage<SshSessionItem>(
'/assets/ssh-sessions',
{ server_id: serverFilter.value || undefined },
page.value,
itemsPerPage.value,
)
sessions.value = res.items
total.value = res.total
} else {
const params: Record<string, string | number> = {
page: page.value,
per_page: itemsPerPage.value,
}
const params: Record<string, unknown> = {}
if (serverFilter.value != null) params.server_id = serverFilter.value
if (pushStatusFilter.value) params.status = pushStatusFilter.value
if (pushSyncModeFilter.value) params.sync_mode = pushSyncModeFilter.value
if (pushTriggerFilter.value) params.trigger_type = pushTriggerFilter.value
const res = await http.get<{ items: PushItem[]; total: number }>('/servers/logs', params)
pushLogs.value = res.items || []
total.value = res.total || 0
const res = await fetchPagePerPage<PushItem>('/servers/logs', params, page.value, itemsPerPage.value)
pushLogs.value = res.items
total.value = res.total
}
if (itemsPerPage.value === -1) {
page.value = 1
}
} catch (e: unknown) {
commands.value = []
+25 -16
View File
@@ -25,6 +25,7 @@
hover
density="comfortable"
:items-per-page="20"
:items-per-page-options="dataTablePageOptions"
>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatPresetTime(item.created_at) }}</span>
@@ -48,10 +49,11 @@
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadSshKeys() }"
@update:items-per-page="(n: number) => { itemsPerPage = n; page = 1; loadSshKeys() }"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.private_key_set="{ item }">
<v-chip :color="item.private_key_set ? 'success' : 'grey'" size="small" variant="tonal" label>
@@ -77,10 +79,11 @@
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadDbCreds() }"
@update:items-per-page="(n: number) => { itemsPerPage = n; page = 1; loadDbCreds() }"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editDbCred(item)">编辑</v-btn>
@@ -193,9 +196,19 @@ import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
function onItemsPerPageChange(n: number) {
itemsPerPage.value = n
page.value = 1
if (credTab.value === 'sshkeys') loadSshKeys()
else if (credTab.value === 'db') loadDbCreds()
}
function formatPresetTime(iso: string | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
@@ -221,36 +234,36 @@ const itemsPerPage = ref(20)
const passwords = ref<PasswordItem[]>([])
const showPasswordDialog = ref(false)
const pwForm = ref({ name: '', username: 'root', password: '' })
const passwordHeaders = [
const passwordHeaders = headersWithoutSort([
{ title: '预设名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '创建时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
]
])
// SSH keys
const sshKeys = ref<SshKeyItem[]>([])
const showSshKeyDialog = ref(false)
const skForm = ref({ name: '', username: 'root', public_key: '', private_key: '' })
const sshKeyHeaders = [
const sshKeyHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '私钥', key: 'private_key_set', width: 120 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
]
])
// DB credentials
const dbCreds = ref<DbCredItem[]>([])
const showDbCredDialog = ref(false)
const dbForm = ref({ name: '', host: '', port: 3306, username: '', password: '', database: '' })
const dbCredHeaders = [
const dbCredHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '主机', key: 'host' },
{ title: '端口', key: 'port', width: 80 },
{ title: '用户名', key: 'username' },
{ title: '数据库', key: 'database' },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
]
])
// Delete
const showDeleteDialog = ref(false)
@@ -273,12 +286,10 @@ async function loadPasswords() {
async function loadSshKeys() {
loading.value = true
try {
const res = await http.getList<SshKeyItem>('/ssh-key-presets/', {
page: page.value,
per_page: itemsPerPage.value,
})
const res = await fetchPagePerPage<SshKeyItem>('/ssh-key-presets/', {}, page.value, itemsPerPage.value)
sshKeys.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
sshKeys.value = []
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
@@ -288,12 +299,10 @@ async function loadSshKeys() {
async function loadDbCreds() {
loading.value = true
try {
const res = await http.getList<DbCredItem>('/scripts/credentials', {
page: page.value,
per_page: itemsPerPage.value,
})
const res = await fetchPagePerPage<DbCredItem>('/scripts/credentials', {}, page.value, itemsPerPage.value)
dbCreds.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
dbCreds.value = []
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
+8 -3
View File
@@ -23,6 +23,9 @@
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
v-model:sort-by="sortBy"
@update:sort-by="onSortByChange"
hover
item-value="id"
@update:page="onPageChange"
@@ -243,7 +246,9 @@ import { auditSummary } from '@/utils/auditLabels'
import { normalizeAuditList } from '@/utils/auditNormalize'
import type { ServerApiItem, AuditItem, AlertLogItem } from '@/types/api'
import StatCardsRow from '@/components/StatCardsRow.vue'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const router = useRouter()
@@ -258,8 +263,8 @@ const statItems = ref([
// ── Server Table (composable) ──
const {
servers, loading, page, itemsPerPage, total, search,
loadServers: _loadServers, onSearch, onPageChange, onItemsPerPageChange,
servers, loading, page, itemsPerPage, total, search, sortBy,
loadServers: _loadServers, onSearch, onPageChange, onItemsPerPageChange, onSortByChange,
} = useServerPagination()
async function loadServers() {
@@ -277,7 +282,7 @@ const headers = [
{ title: '分类', key: 'category', width: 100 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 140 },
{ title: '操作', key: 'actions', width: 140, align: 'end' as const },
{ title: '操作', key: 'actions', width: 140, align: 'end' as const, sortable: false },
]
// ── Alerts ──
+4 -3
View File
@@ -92,6 +92,7 @@ import { ref, onMounted } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, RetryItem } from '@/types/api'
import { headersWithoutSort } from '@/constants/dataTable'
const snackbar = useSnackbar()
@@ -112,15 +113,15 @@ const statusOptions = [
{ label: '失败', value: 'failed' },
]
const headers = [
const headers = headersWithoutSort([
{ title: '状态', key: 'status', width: 100 },
{ title: '任务', key: 'operation' },
{ title: '服务器', key: 'server_name' },
{ title: '次数', key: 'retry_count', width: 100 },
{ title: '下次重试', key: 'next_retry_at', width: 140 },
{ title: '创建时间', key: 'created_at', width: 160 },
{ title: '', key: 'actions', width: 120, align: 'end' as const, sortable: false },
]
{ title: '', key: 'actions', width: 120, align: 'end' as const },
])
// ── Data loading ──
async function loadRetries() {
+6 -4
View File
@@ -14,6 +14,7 @@
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="onPageChange"
@@ -210,7 +211,9 @@ import { http } from '@/api'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import type { ScheduleItem, ScriptItem } from '@/types/api'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort, sliceClientPage } from '@/constants/dataTable'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const { servers: serverList, loadServers } = useServerList()
@@ -254,7 +257,7 @@ const form = ref({
const showDelete = ref(false)
const deletingSchedule = ref<ScheduleItem | null>(null)
const headers = [
const headers = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '类型', key: 'schedule_type', width: 80 },
{ title: '模式', key: 'run_mode', width: 72 },
@@ -267,7 +270,7 @@ const headers = [
{ title: '下次运行', key: 'next_run', width: 140 },
{ title: '启用', key: 'enabled', width: 80 },
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
]
])
function syncModeLabel(mode?: string): string {
return syncModeOptions.find((o) => o.value === mode)?.label || mode || '增量'
@@ -378,8 +381,7 @@ async function loadSchedules() {
const res = await http.getList<ScheduleItem>('/schedules/')
const all = (res.items || []).map(normalizeSchedule)
totalItems.value = all.length
const start = (page.value - 1) * itemsPerPage.value
schedules.value = all.slice(start, start + itemsPerPage.value)
schedules.value = sliceClientPage(all, page.value, itemsPerPage.value)
} catch (e: unknown) {
schedules.value = []
totalItems.value = 0
+38 -37
View File
@@ -47,21 +47,15 @@
@keydown.ctrl.enter="doQuickExec"
:rules="[required('命令')]"
/>
<div class="text-caption text-medium-emphasis mb-1">目标服务器按分类选择</div>
<ServerCategoryPicker
v-model="quickServerIds"
:servers="serverList"
:loading="serversLoading"
max-height="220px"
class="mb-3"
/>
<v-row dense>
<v-col cols="12" md="5">
<v-select
v-model="quickServerIds"
:items="serverList"
item-title="name"
item-value="id"
label="目标服务器"
variant="outlined"
density="compact"
multiple
chips
closable-chips
/>
</v-col>
<v-col cols="6" md="2">
<v-text-field
v-model.number="quickTimeout"
@@ -227,6 +221,7 @@
:loading="allExecLoading"
:page="allExecPage"
:items-per-page="allExecPerPage"
:items-per-page-options="dataTablePageOptions"
density="compact"
hover
@update:page="onAllExecPageChange"
@@ -372,20 +367,16 @@
</v-dialog>
<!-- Run Dialog -->
<v-dialog v-model="showRun" max-width="640">
<v-dialog v-model="showRun" max-width="720" scrollable>
<v-card border>
<v-card-title>执行脚本: {{ runningScript?.name }}</v-card-title>
<v-card-text>
<v-select
<div class="text-caption text-medium-emphasis mb-1">选择服务器按分类勾选</div>
<ServerCategoryPicker
v-model="runServerIds"
:items="serverList"
item-title="name"
item-value="id"
label="选择服务器"
variant="outlined"
density="compact"
multiple
chips
:servers="serverList"
:loading="serversLoading"
max-height="360px"
class="mb-2"
/>
<v-row dense>
@@ -491,15 +482,19 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import ScriptContentEditor from '@/components/ScriptContentEditor.vue'
import ServerCategoryPicker from '@/components/servers/ServerCategoryPicker.vue'
import { http } from '@/api'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
import type { DbCredentialItem, ScriptExecItem, ScriptItem } from '@/types/api'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchLimitOffset } from '@/utils/paginatedFetch'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const { servers: serverList, loadServers } = useServerList()
const { servers: serverList, loading: serversLoading, loadServers } = useServerList()
const categoryOptions = [
{ label: '运维', value: 'ops' },
@@ -605,7 +600,7 @@ const pagedScripts = computed(() => {
watch(search, () => { page.value = 1 })
const allExecHeaders = [
const allExecHeaders = headersWithoutSort([
{ title: 'ID', key: 'id', width: 72 },
{ title: '脚本/命令', key: 'label' },
{ title: '状态', key: 'status', width: 100 },
@@ -613,8 +608,8 @@ const allExecHeaders = [
{ title: '台数', key: 'server_count', width: 72 },
{ title: '操作人', key: 'operator', width: 88 },
{ title: '开始时间', key: 'started_at', width: 168 },
{ title: '操作', key: 'actions', width: 180, sortable: false },
]
{ title: '操作', key: 'actions', width: 180 },
])
const filteredScriptName = computed(() => {
if (!historyScriptFilter.value) return ''
@@ -696,15 +691,21 @@ async function loadRecentExecutions() {
async function loadAllExecutions() {
allExecLoading.value = true
try {
const offset = (allExecPage.value - 1) * allExecPerPage.value
const params: Record<string, unknown> = {
limit: allExecPerPage.value,
offset,
const res = await fetchLimitOffset<ScriptExecItem>(
async (limit, offset) => {
const params: Record<string, unknown> = { limit, offset }
if (historyScriptFilter.value) params.script_id = historyScriptFilter.value
const data = await http.get<{ items: ScriptExecItem[]; total: number }>('/scripts/executions', params)
return { items: data.items || [], total: data.total || 0 }
},
allExecPage.value,
allExecPerPage.value,
)
allExecutions.value = res.items
allExecTotal.value = res.total
if (allExecPerPage.value === -1) {
allExecPage.value = 1
}
if (historyScriptFilter.value) params.script_id = historyScriptFilter.value
const res = await http.get<{ items: ScriptExecItem[]; total: number }>('/scripts/executions', params)
allExecutions.value = res.items || []
allExecTotal.value = res.total || 0
} catch {
allExecutions.value = []
allExecTotal.value = 0
@@ -1075,7 +1076,7 @@ function execStatusLabel(s: string) {
onMounted(() => {
loadScripts()
loadServers()
loadServers({ all: true })
loadExecConfig()
loadCredentials()
refreshExecutionHistory()
+283 -13
View File
@@ -41,13 +41,45 @@
>
检测路径
</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">
<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" />
</div>
</v-card-text>
<!-- 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="openBatchCategory">修改分类</v-btn>
<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>
@@ -59,17 +91,21 @@
</v-card>
<v-data-table-server
v-if="viewMode === 'table'"
: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"
@update:sort-by="onSortByChange"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
@click:row="(_e: Event, { item }: { item: ServerApiItem }) => showDetail(item)"
@@ -84,7 +120,7 @@
<span class="font-weight-medium">{{ item.name }}</span>
</template>
<template #item.address="{ item }">
<template #item.domain="{ item }">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
</template>
@@ -129,11 +165,11 @@
</div>
</template>
<template #item.agent="{ item }">
<template #item.agent_version="{ item }">
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
</template>
<template #item.heartbeat="{ item }">
<template #item.last_heartbeat="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_heartbeat) }}</span>
</template>
@@ -150,6 +186,70 @@
<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">
<v-expansion-panel v-for="group in groupedServers" :key="group.category">
<v-expansion-panel-title>
<div class="d-flex align-center ga-2">
<v-chip color="primary" variant="tonal" size="small" label>{{ group.label }}</v-chip>
<span class="text-caption text-medium-emphasis">{{ group.servers.length }} </span>
</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"
class="cursor-pointer"
@click="showDetail(item)"
>
<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>
<!-- Pending / failed SSH connections -->
@@ -402,22 +502,51 @@
:results="batchResultItems"
/>
<!-- 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">
已选择 {{ selectedIds.size }} 台服务器将统一设置为以下分类留空表示清除分类
</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 或域名空白行自动忽略重复地址自动去重不区分大小写SSH 凭据从凭据管理预设轮询匹配连接失败将进入下方连接失败列表
每行一个 IP 或域名也可使用名称 + Tab + IP附带显示名称空白行自动忽略重复地址自动去重不区分大小写SSH 凭据从凭据管理预设轮询匹配连接失败将进入下方连接失败列表
</div>
<v-textarea
v-model="batchAddText"
label="IP / 域名列表"
label="IP / 域名列表(可选:名称 Tab IP"
variant="outlined"
density="compact"
rows="10"
auto-grow
placeholder="192.168.1.10&#10;192.168.1.11&#10;10.0.0.5"
placeholder="武汉示例公司 192.168.1.10&#10;192.168.1.11&#10;10.0.0.5"
/>
<div v-if="batchAddPreview.unique > 0" class="text-caption text-medium-emphasis mt-1">
将添加 {{ batchAddPreview.unique }}
@@ -462,6 +591,7 @@ import { useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
import { formatPushPermission } from '@/composables/push/labels'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import type { AddByIpResponse, AddByIpsBatchResult, PendingServerItem, PollErrorItem, PushItem, ServerApiItem } from '@/types/api'
@@ -471,24 +601,96 @@ 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'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS } from '@/constants/dataTable'
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const router = useRouter()
// ── Paginated server list (composable) ──
const {
servers, loading, page, itemsPerPage, total, search,
loadServers: _load, onSearch, onPageChange, onItemsPerPageChange,
servers, loading, page, itemsPerPage, total, search, categoryFilter, sortBy,
loadServers: _load, onSearch, onPageChange, onItemsPerPageChange, onCategoryChange, onSortByChange,
loadAllServers,
} = useServerPagination()
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() {
try {
await _load()
if (viewMode.value === 'group') {
await loadGroupServers()
}
} catch {
snackbar('加载服务器列表失败', 'error')
}
}
async function loadGroupServers() {
groupLoading.value = true
try {
groupServers.value = await loadAllServers()
expandedCategories.value = groupedServers.value.map(g => g.category)
} catch {
groupServers.value = []
snackbar('加载分类视图失败', 'error')
} finally {
groupLoading.value = false
}
}
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) => a.name.localeCompare(b.name, 'zh')),
}))
.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]
}
watch(viewMode, mode => {
if (mode === 'group') {
void loadGroupServers()
}
})
const showDelete = ref(false)
const deleting = ref(false)
const deletingServer = ref<ServerApiItem | null>(null)
@@ -633,6 +835,7 @@ const {
} = useServerFormDialog(() => {
loadServers()
loadStats()
loadCategories()
})
watch(serverFormVisible, (open, wasOpen) => {
@@ -690,12 +893,12 @@ function parseServerSyncLogs(res: unknown): PushItem[] {
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'address' },
{ title: '地址', key: 'domain' },
{ 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 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 200, align: 'end' as const, sortable: false },
]
// ── Stats(与仪表盘 StatCardsRow 一致)──
@@ -724,6 +927,58 @@ function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
// ── 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() {
if (selectedIds.value.size === 0) {
snackbar('请先勾选至少一台服务器', 'error')
return
}
batchCategoryValue.value = ''
showBatchCategory.value = true
}
async function submitBatchCategory() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
batchCategoryLoading.value = true
try {
const category = (batchCategoryValue.value || '').trim()
let updated = 0
const chunkSize = 200
for (let i = 0; i < ids.length; i += chunkSize) {
const res = await http.post<{ updated: number; not_found: number[] }>('/servers/batch/category', {
server_ids: ids.slice(i, i + chunkSize),
category,
})
updated += res.updated
}
showBatchCategory.value = false
selectedItems.value = []
snackbar(`已更新 ${updated} 台服务器分类`)
await Promise.all([loadServers(), loadCategories(), loadStats()])
} catch (e: unknown) {
snackbar(formatApiError(e, '批量修改分类失败'), 'error')
} finally {
batchCategoryLoading.value = false
}
}
// ── Batch operations ──
const batchAgentLoading = ref(false)
const showDetectPathConfirm = ref(false)
@@ -912,12 +1167,26 @@ 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 = line.trim()
const domain = parseBatchLineDomain(line)
if (!domain) continue
raw += 1
const key = domain.toLowerCase()
@@ -1015,6 +1284,7 @@ async function doDelete() {
// ── Init ──
onMounted(() => {
loadStats()
loadCategories()
loadServers()
loadPendingServers()
})
+6
View File
@@ -6,6 +6,7 @@
* 主题优先从 localStorage 恢复(快速),App.vue 初始化时从 MySQL 同步并覆盖。
*/
import { createVuetify } from 'vuetify'
import { zhHans } from 'vuetify/locale'
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
@@ -14,6 +15,11 @@ const savedTheme = typeof localStorage !== 'undefined'
: 'light'
export default createVuetify({
locale: {
locale: 'zhHans',
fallback: 'en',
messages: { zhHans },
},
theme: {
defaultTheme: savedTheme,
},
+9
View File
@@ -8,6 +8,9 @@
export interface PaginatedResponse<T> {
items: T[]
total: number
page?: number
per_page?: number
pages?: number
limit?: number
offset?: number
}
@@ -201,6 +204,12 @@ export interface PendingServerItem {
created_at?: string | null
}
/** Response from POST /api/servers/batch/category */
export interface BatchCategoryResult {
updated: number
not_found: number[]
}
/** Response from POST /api/servers/add-by-ip and pending retry */
export interface AddByIpResponse {
success: boolean
+62
View File
@@ -0,0 +1,62 @@
import { http } from '@/api'
import type { PaginatedResponse } from '@/types/api'
const API_PAGE_SIZE = 200
/** 服务端 page/per_page 分页;-1 时拉取全部页 */
export async function fetchPagePerPage<T>(
path: string,
params: Record<string, unknown>,
page: number,
itemsPerPage: number,
): Promise<{ items: T[]; total: number }> {
if (itemsPerPage !== -1) {
const res = await http.get<PaginatedResponse<T>>(path, {
...params,
page,
per_page: itemsPerPage,
})
return { items: res.items || [], total: res.total ?? 0 }
}
const all: T[] = []
let p = 1
let pages = 1
let total = 0
do {
const res = await http.get<PaginatedResponse<T>>(path, {
...params,
page: p,
per_page: API_PAGE_SIZE,
})
all.push(...(res.items || []))
total = res.total ?? all.length
pages = res.pages ?? 1
p += 1
} while (p <= pages)
return { items: all, total }
}
/** 服务端 limit/offset 分页;-1 时拉取全部 */
export async function fetchLimitOffset<T>(
fetchPage: (limit: number, offset: number) => Promise<{ items: T[]; total: number }>,
page: number,
itemsPerPage: number,
): Promise<{ items: T[]; total: number }> {
if (itemsPerPage !== -1) {
const offset = (page - 1) * itemsPerPage
return fetchPage(itemsPerPage, offset)
}
const all: T[] = []
let offset = 0
let total = 0
do {
const res = await fetchPage(API_PAGE_SIZE, offset)
all.push(...(res.items || []))
total = res.total ?? all.length
if (!res.items.length || all.length >= total) break
offset += API_PAGE_SIZE
} while (true)
return { items: all, total }
}
+23
View File
@@ -0,0 +1,23 @@
/** Vuetify v-data-table sort-by item */
export type TableSortItem = { key: string; order: 'asc' | 'desc' }
/** 表头 key → GET /api/servers/ sort_by(白名单与 server_repo._SORT_COLUMNS 一致) */
const SERVER_COLUMN_TO_API: Record<string, string> = {
status: 'status',
name: 'name',
domain: 'domain',
address: 'domain',
category: 'category',
target_path: 'target_path',
agent_version: 'agent_version',
agent: 'agent_version',
last_heartbeat: 'heartbeat',
heartbeat: 'heartbeat',
}
export function toServerApiSort(sortBy: TableSortItem[]): { sort_by?: string; sort_order?: 'asc' | 'desc' } {
if (!sortBy.length) return {}
const apiKey = SERVER_COLUMN_TO_API[sortBy[0].key]
if (!apiKey) return {}
return { sort_by: apiKey, sort_order: sortBy[0].order }
}
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Import servers from Excel (.xls) via Nexus API (name + IP columns).
Usage:
NEXUS_TEST_BASE=https://api.synaglobal.vip \\
NEXUS_TEST_ADMIN_USER=admin \\
NEXUS_TEST_ADMIN_PASSWORD='***' \\
python scripts/import_servers_from_xls.py /path/to/servers.xls
Options:
--dry-run Parse only, do not call API
--batch Use /add-by-ips-batch (requires server with name-tab support)
--chunk N Batch chunk size (default 200)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _load_test_credentials() -> tuple[str, str, str]:
sys.path.insert(0, str(ROOT / "tests"))
import test_api # noqa: E402
test_api._load_credentials_from_env_file()
base = os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600").rstrip("/")
user = os.environ.get("NEXUS_TEST_ADMIN_USER", "admin")
password = os.environ.get("NEXUS_TEST_ADMIN_PASSWORD", "admin")
return base, user, password
def _api_json(method: str, url: str, token: str | None = None, body: dict | None = None, timeout: int = 300):
data = None
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
if body is not None:
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
return resp.status, json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
raw = e.read()
try:
payload = json.loads(raw) if raw else {"detail": e.reason}
except json.JSONDecodeError:
payload = {"detail": raw.decode(errors="replace")}
return e.code, payload
def _login(base: str, user: str, password: str) -> str:
code, data = _api_json("POST", f"{base}/api/auth/login", body={"username": user, "password": password})
if code != 200 or not data.get("access_token"):
detail = data.get("detail", data)
raise SystemExit(f"登录失败 ({code}): {detail}")
return data["access_token"]
def _read_xls(path: Path) -> list[tuple[str, str]]:
try:
import xlrd
except ImportError as e:
raise SystemExit("需要 xlrd: pip install xlrd") from e
wb = xlrd.open_workbook(str(path))
sh = wb.sheet_by_index(0)
rows: list[tuple[str, str]] = []
for r in range(sh.nrows):
if sh.ncols < 2:
continue
name = str(sh.cell_value(r, 0)).strip()
ip = str(sh.cell_value(r, 1)).strip()
if not ip:
continue
rows.append((name or ip, ip))
return rows
def _dedupe_rows(rows: list[tuple[str, str]]) -> tuple[list[tuple[str, str]], int]:
seen: set[str] = set()
out: list[tuple[str, str]] = []
removed = 0
for name, ip in rows:
key = ip.casefold()
if key in seen:
removed += 1
continue
seen.add(key)
out.append((name, ip))
return out, removed
def _import_single(base: str, token: str, rows: list[tuple[str, str]], port: int, agent_port: int) -> dict:
stats = {"created": 0, "pending": 0, "skipped": 0, "errors": 0}
total = len(rows)
for i, (name, ip) in enumerate(rows, 1):
code, data = _api_json(
"POST",
f"{base}/api/servers/add-by-ip",
token=token,
body={
"domain": ip,
"name": name,
"port": port,
"agent_port": agent_port,
},
timeout=300,
)
if code == 200 and data.get("success"):
stats["created"] += 1
status = "created"
elif code == 409:
stats["skipped"] += 1
status = "skipped"
elif code == 200 and not data.get("success"):
stats["pending"] += 1
status = "pending"
else:
stats["errors"] += 1
status = f"error:{code}"
print(f"[{i}/{total}] {status} {name} ({ip})", flush=True)
return stats
def _import_batch(
base: str,
token: str,
rows: list[tuple[str, str]],
port: int,
agent_port: int,
chunk: int,
) -> dict:
stats = {"created": 0, "pending": 0, "skipped": 0, "errors": 0}
for start in range(0, len(rows), chunk):
part = rows[start : start + chunk]
text = "\n".join(f"{name}\t{ip}" for name, ip in part)
code, data = _api_json(
"POST",
f"{base}/api/servers/add-by-ips-batch",
token=token,
body={"text": text, "port": port, "agent_port": agent_port},
timeout=3600,
)
if code != 200:
stats["errors"] += len(part)
print(f"batch {start // chunk + 1} failed ({code}): {data.get('detail', data)}", flush=True)
continue
stats["created"] += int(data.get("created", 0))
stats["pending"] += int(data.get("pending", 0))
stats["skipped"] += int(data.get("skipped", 0))
print(
f"batch {start // chunk + 1}: created={data.get('created')} "
f"pending={data.get('pending')} skipped={data.get('skipped')}",
flush=True,
)
return stats
def main() -> None:
parser = argparse.ArgumentParser(description="Import servers from Excel via Nexus API")
parser.add_argument("xls", type=Path, help="Excel file (.xls), column A=name, B=IP")
parser.add_argument("--dry-run", action="store_true", help="Parse only")
parser.add_argument("--batch", action="store_true", help="Use batch API instead of single add-by-ip")
parser.add_argument("--chunk", type=int, default=200, help="Batch chunk size")
parser.add_argument("--port", type=int, default=22)
parser.add_argument("--agent-port", type=int, default=8601)
args = parser.parse_args()
if not args.xls.is_file():
raise SystemExit(f"文件不存在: {args.xls}")
rows = _read_xls(args.xls)
rows, dup_removed = _dedupe_rows(rows)
print(f"读取 {args.xls.name}: {len(rows)} 台(去重忽略 {dup_removed} 行重复 IP")
if args.dry_run:
for name, ip in rows[:5]:
print(f" {name}\t{ip}")
if len(rows) > 5:
print(f" ... 共 {len(rows)}")
return
base, user, password = _load_test_credentials()
print(f"目标 API: {base} 用户: {user}")
token = _login(base, user, password)
started = time.time()
if args.batch:
stats = _import_batch(base, token, rows, args.port, args.agent_port, args.chunk)
else:
stats = _import_single(base, token, rows, args.port, args.agent_port)
elapsed = int(time.time() - started)
print(
f"完成 ({elapsed}s): 成功 {stats['created']},待连接 {stats['pending']}"
f"跳过 {stats['skipped']},错误 {stats['errors']}"
)
if __name__ == "__main__":
main()
+13 -2
View File
@@ -134,6 +134,17 @@ class BatchAgentResult(BaseModel):
results: List[BatchAgentResultItem] = []
class BatchCategoryUpdate(BaseModel):
"""Batch update server category for selected IDs."""
server_ids: List[int] = Field(..., min_length=1, max_length=200)
category: str = Field("", max_length=100, description="分类名称;留空表示清除分类")
class BatchCategoryResult(BaseModel):
updated: int = 0
not_found: List[int] = []
class FileUpload(BaseModel):
"""Upload file to remote server via SFTP."""
server_id: int = Field(..., ge=1)
@@ -653,8 +664,8 @@ class AddByIpRequest(BaseModel):
class AddByIpsBatchRequest(BaseModel):
"""批量按 IP 添加:每行一个地址,凭据预设轮询 SSH,失败入 pending。"""
text: str = Field(..., min_length=1, max_length=50_000, description="每行一个 IP 或域名")
"""批量按 IP 添加:每行一个地址(可选 ``名称<TAB>IP``,凭据预设轮询 SSH,失败入 pending。"""
text: str = Field(..., min_length=1, max_length=50_000, description="每行一个 IP/域名,或 名称<TAB>IP")
port: int = Field(22, ge=1, le=65535)
agent_port: int = Field(8601, ge=1, le=65535)
target_path: Optional[str] = Field("", description="推送目标路径,可后续编辑")
+43 -8
View File
@@ -19,6 +19,7 @@ from server.api.schemas import (
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
ServerImportResult, BatchAgentAction, BatchAgentResult, BatchAgentResultItem,
AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult,
BatchCategoryUpdate, BatchCategoryResult,
)
from server.application.services.server_service import ServerService
from server.application.services.sync_service import SyncService
@@ -329,7 +330,7 @@ async def get_all_sync_logs(
MAX_IMPORT_FILE_SIZE = 1_048_576 # 1 MB
MAX_IMPORT_ROWS = 500
MAX_BATCH_IP_LINES = 200
MAX_BATCH_IP_LINES = 500
# CSV column mapping: CSV header (Chinese) → Server model field
CSV_COLUMNS = [
@@ -574,6 +575,40 @@ async def import_servers(
return result
# ── Batch category update ──
@router.post("/batch/category", response_model=BatchCategoryResult)
async def batch_update_category(
request: Request,
payload: BatchCategoryUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Batch update category for multiple servers."""
from server.infrastructure.database.server_repo import ServerRepositoryImpl
repo = ServerRepositoryImpl(db)
servers = await repo.get_by_ids(payload.server_ids)
found_ids = {s.id for s in servers}
not_found = [sid for sid in payload.server_ids if sid not in found_ids]
category = (payload.category or "").strip() or None
updated = await repo.update_category_batch(list(found_ids), category)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="batch_update_category",
target_type="server",
target_id=None,
detail=f"批量修改分类={category or '(清除)'} 更新{updated}",
ip_address=ip_address,
))
return BatchCategoryResult(updated=updated, not_found=not_found)
# ── Batch Agent Install/Upgrade ──
@router.post("/batch/install-agent", response_model=BatchAgentResult)
@@ -1178,24 +1213,24 @@ async def add_servers_by_ips_batch(
except ValueError as e:
raise HTTPException(400, str(e)) from e
domains = parsed.domains
if not domains:
entries = parsed.entries
if not entries:
raise HTTPException(400, "未解析到有效 IP 或域名")
items: list[AddByIpsBatchItemResult] = []
created = pending = skipped = 0
for domain in domains:
for entry in entries:
item = await _try_add_by_ip(
request,
domain=domain,
domain=entry.domain,
port=payload.port,
agent_port=payload.agent_port,
target_path=payload.target_path,
category=payload.category,
platform_id=payload.platform_id,
node_id=payload.node_id,
name=None,
name=entry.name,
admin=admin,
service=service,
db=db,
@@ -1217,13 +1252,13 @@ async def add_servers_by_ips_batch(
target_id=None,
detail=(
f"批量 IP 添加 输入{parsed.input_lines}行 去重{parsed.duplicates_removed} "
f"实处理{len(domains)} 成功{created} 待连接{pending} 跳过{skipped}"
f"实处理{len(entries)} 成功{created} 待连接{pending} 跳过{skipped}"
),
ip_address=ip_address,
))
return AddByIpsBatchResult(
total=len(domains),
total=len(entries),
created=created,
pending=pending,
skipped=skipped,
@@ -139,33 +139,77 @@ async def poll_credentials(
return PollResult(success=False, errors=errors)
@dataclass(frozen=True)
class BatchServerEntry:
"""One host from batch add input."""
domain: str
name: str | None = None
@dataclass(frozen=True)
class BatchIpParseResult:
"""Parsed batch IP input after blank-skip and dedupe."""
domains: list[str]
entries: list[BatchServerEntry]
input_lines: int = 0
duplicates_removed: int = 0
@property
def domains(self) -> list[str]:
return [e.domain for e in self.entries]
def _looks_like_host(value: str) -> bool:
value = value.strip()
if not value:
return False
if value.replace(".", "").isdigit():
parts = value.split(".")
return len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts)
return "." in value or ":" in value
def _parse_batch_line(raw_line: str) -> BatchServerEntry | None:
"""Parse one line: IP/domain only, or ``名称<TAB>IP`` / ``名称,IP``."""
line = raw_line.strip()
if not line:
return None
if "\t" in line:
name_part, domain = line.split("\t", 1)
domain = domain.strip()
name = name_part.strip()
if domain and name:
return BatchServerEntry(domain=domain, name=name)
if "," in line:
name_part, _, domain_part = line.rpartition(",")
domain = domain_part.strip()
name = name_part.strip()
if domain and name and _looks_like_host(domain):
return BatchServerEntry(domain=domain, name=name)
return BatchServerEntry(domain=line, name=None)
def parse_batch_ip_lines(text: str, *, max_lines: int = 200) -> BatchIpParseResult:
"""Parse multi-line IP/domain input: one per line, ignore blanks, dedupe in order (case-insensitive)."""
"""Parse multi-line batch input: one host per line, optional name prefix, dedupe by domain."""
seen: set[str] = set()
result: list[str] = []
result: list[BatchServerEntry] = []
input_lines = 0
for raw_line in text.splitlines():
domain = raw_line.strip()
if not domain:
entry = _parse_batch_line(raw_line)
if entry is None:
continue
input_lines += 1
key = domain.casefold()
key = entry.domain.casefold()
if key in seen:
continue
seen.add(key)
result.append(domain)
result.append(entry)
if len(result) > max_lines:
raise ValueError(f"单次最多添加 {max_lines} 个地址")
return BatchIpParseResult(
domains=result,
entries=result,
input_lines=input_lines,
duplicates_removed=max(0, input_lines - len(result)),
)
+23 -2
View File
@@ -4,11 +4,21 @@ Concrete implementation of ServerRepository protocol.
from datetime import datetime, timezone
from typing import Optional, List, Tuple
from sqlalchemy import select, update, func, asc, desc
from sqlalchemy import select, update, func, asc, desc, or_
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Server
UNCATEGORIZED_CATEGORY = "uncategorized"
def _category_filter(category: str):
"""Map API category filter to SQLAlchemy criterion."""
if category == UNCATEGORIZED_CATEGORY:
return or_(Server.category.is_(None), Server.category == "")
return Server.category == category
# Whitelist of sortable columns (prevents SQL injection via sort_by)
_SORT_COLUMNS = {
"id": Server.id,
@@ -18,6 +28,7 @@ _SORT_COLUMNS = {
"heartbeat": Server.last_heartbeat,
"agent_version": Server.agent_version,
"category": Server.category,
"target_path": Server.target_path,
}
@@ -64,7 +75,7 @@ class ServerRepositoryImpl:
"""
filters = []
if category:
filters.append(Server.category == category)
filters.append(_category_filter(category))
if platform_id:
filters.append(Server.platform_id == platform_id)
if node_id:
@@ -121,6 +132,16 @@ class ServerRepositoryImpl:
await self.session.commit()
return server
async def update_category_batch(self, ids: List[int], category: Optional[str]) -> int:
"""Set category for multiple servers in one UPDATE."""
if not ids:
return 0
result = await self.session.execute(
update(Server).where(Server.id.in_(ids)).values(category=category)
)
await self.session.commit()
return int(result.rowcount or 0)
async def delete(self, id: int) -> bool:
server = await self.get_by_id(id)
if server:
+15
View File
@@ -178,6 +178,21 @@ def test_parse_batch_ip_lines_max_limit():
parse_batch_ip_lines("1\n2\n3", max_lines=2)
def test_parse_batch_ip_lines_name_tab_ip():
parsed = parse_batch_ip_lines("武汉测试公司\t192.168.1.1\n10.0.0.2")
assert len(parsed.entries) == 2
assert parsed.entries[0].domain == "192.168.1.1"
assert parsed.entries[0].name == "武汉测试公司"
assert parsed.entries[1].domain == "10.0.0.2"
assert parsed.entries[1].name is None
def test_parse_batch_ip_lines_name_comma_ip():
parsed = parse_batch_ip_lines("广州示例,47.107.1.2")
assert parsed.entries[0].domain == "47.107.1.2"
assert parsed.entries[0].name == "广州示例"
def test_format_poll_errors_truncates():
errors = [
CredentialAttemptError("password", "a", "root", "e1"),
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" href="/app/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus</title>
<script type="module" crossorigin src="/app/assets/index-3SWW3Wj_.js"></script>
<script type="module" crossorigin src="/app/assets/index-HeC-ymfd.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BGzIPEZO.css">
</head>
<body>