feat(servers): 分类筛选/批量改分类、分页中文与列排序,脚本库按分类选机
支持 300+ 台服务器按分类浏览与批量管理,修复分页「全部」与排序;门控 Gate3 强制本地 API。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 显式 HTTPException;poller 记录失败 |
|
||||
|
||||
## 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 1–7)
|
||||
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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 192.168.1.11 10.0.0.5"
|
||||
placeholder="武汉示例公司 192.168.1.10 192.168.1.11 10.0.0.5"
|
||||
/>
|
||||
<div v-if="batchAddPreview.unique > 0" class="text-caption text-medium-emphasis mt-1">
|
||||
将添加 {{ batchAddPreview.unique }} 台
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user