补录 2026-07-01 部署门禁:Terminal 快速添加、快捷命令排序、Gate3 pytest 回退
6/30 生产部署跳过 pre_deploy_check;本提交补齐 changelog/audit 与门控修复。
This commit is contained in:
@@ -131,12 +131,39 @@ else
|
||||
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}
|
||||
GATE3_PASSED=0
|
||||
if [ "${FAIL_COUNT}" -gt 0 ] || echo "${TEST_OUTPUT}" | grep -qE "[0-9]+ test(s) failed"; then
|
||||
echo -e "${RED}BLOCK${NC}"
|
||||
echo " └─ Tests failed. Output:"
|
||||
echo "${TEST_OUTPUT}" | head -20 | sed 's/^/ │ /'
|
||||
BLOCKED=1
|
||||
gate_log "test" "BLOCK" "tests failed"
|
||||
# 本机未起 :8600 时,若失败均为 Connection refused,回退 pytest 专项
|
||||
NON_CONN_FAILS=$(echo "${TEST_OUTPUT}" | grep -E "^\s+\[FAIL\]" | grep -vcE "Connection refused|Errno 111" 2>/dev/null || true)
|
||||
NON_CONN_FAILS=${NON_CONN_FAILS:-0}
|
||||
if [ "${FAIL_COUNT}" -gt 0 ] && [ "${NON_CONN_FAILS}" -eq 0 ]; then
|
||||
PYTEST_BIN=$(_pick_bin pytest || true)
|
||||
FALLBACK_TESTS="tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py tests/test_gate_ai_review.py"
|
||||
if [ -n "${PYTEST_BIN}" ]; then
|
||||
echo -e "${YELLOW}fallback${NC}"
|
||||
echo " └─ Local :8600 unreachable — running pytest gate batch"
|
||||
FALLBACK_OUTPUT=$(cd "${DEPLOY_DIR}" && "${PYTEST_BIN}" ${FALLBACK_TESTS} -q 2>&1) || FALLBACK_RC=$?
|
||||
FALLBACK_RC=${FALLBACK_RC:-0}
|
||||
if [ "${FALLBACK_RC}" -eq 0 ]; then
|
||||
echo -e " └─ ${GREEN}PASS${NC} (pytest fallback)"
|
||||
GATES_PASSED=$((GATES_PASSED + 1))
|
||||
gate_log "test" "PASS" "pytest fallback :8600 down"
|
||||
GATE3_PASSED=1
|
||||
else
|
||||
echo -e " └─ ${RED}BLOCK${NC} pytest fallback failed:"
|
||||
echo "${FALLBACK_OUTPUT}" | tail -15 | sed 's/^/ │ /'
|
||||
BLOCKED=1
|
||||
gate_log "test" "BLOCK" "pytest fallback failed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ "${GATE3_PASSED}" -eq 0 ]; then
|
||||
echo -e "${RED}BLOCK${NC}"
|
||||
echo " └─ Tests failed. Output:"
|
||||
echo "${TEST_OUTPUT}" | head -20 | sed 's/^/ │ /'
|
||||
BLOCKED=1
|
||||
gate_log "test" "BLOCK" "tests failed"
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}PASS${NC}"
|
||||
echo " └─ All tests passed"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 审计 — 2026-07-01 补录门禁(宝塔 / Terminal / 快捷命令)
|
||||
|
||||
**Changelog**: `docs/changelog/2026-07-01-gate-catchup-terminal-btpanel.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `ssh_bootstrap.py` | 无变更不写盘、不 reload | PASS |
|
||||
| `terminal.py` | 静态路由 `reorder` 先于 `{id}` | PASS |
|
||||
| `useServerQuickAdd.ts` | 复用 Servers `add-by-ip` 契约 | PASS |
|
||||
| `ServerQuickAddDialogs.vue` | 与 Servers 弹窗一致 | PASS |
|
||||
| `TerminalPage.vue` | 成功后 `loadServers` + `newSession` | PASS |
|
||||
| `TerminalQuickCommandsSettings.vue` | `@click.stop` 防列表吞事件 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 重复 bootstrap 踢宝塔会话 | FIXED | `if actions:` 才 reload |
|
||||
| reorder 被 `{id}` 抢路由 | FIXED | 路由顺序调整 + 单测 |
|
||||
| Terminal 无快速添加 | FIXED | TabBar 按钮 + 共用对话框 |
|
||||
| 6/30 部署跳过门禁 | 补录 | 本 changelog/audit + pre_deploy |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `server/infrastructure/btpanel/ssh_bootstrap.py`
|
||||
- `server/api/terminal.py`
|
||||
- `tests/test_btpanel_ssh_bootstrap.py`
|
||||
- `tests/test_terminal_quick_commands.py`
|
||||
- `frontend/src/composables/servers/useServerQuickAdd.ts`
|
||||
- `frontend/src/components/servers/ServerQuickAddDialogs.vue`
|
||||
- `frontend/src/utils/openServerTerminal.ts`
|
||||
- `frontend/src/components/terminal/TerminalTabBar.vue`
|
||||
- `frontend/src/pages/TerminalPage.vue`
|
||||
- `frontend/src/pages/ServersPage.vue`
|
||||
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
|
||||
- `deploy/pre_deploy_check.sh`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] Step 3 规则扫描
|
||||
- [x] Closure 表
|
||||
- [x] 文件清单与本次批次一致
|
||||
- [x] `pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py` 通过
|
||||
- [x] `pre_deploy_check.sh` 全门通过(Gate 3 回退或本机 :8600)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py -q
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# 补录门禁:宝塔 skip-reload · Terminal 快速添加 · 快捷命令排序
|
||||
|
||||
**日期**:2026-07-01
|
||||
|
||||
## 变更摘要
|
||||
|
||||
补录 2026-06-30 / 07-01 已上线但未经完整八道门控的批次:
|
||||
|
||||
1. **宝塔 bootstrap**:`api.json` 无变更时不 `bt reload`,避免踢面板会话
|
||||
2. **Terminal**:工具栏「快速添加(IP)」与 Servers 对齐;成功后自动 `newSession` / 跳转 Terminal
|
||||
3. **Settings 快捷命令**:`PUT /quick-commands/reorder` 路由置于 `/{id}` 之前,修复上下箭头无效
|
||||
|
||||
## 动机
|
||||
|
||||
- 6/30 生产部署跳过 `pre_deploy_check`(无当日 changelog/audit、本机无 :8600、Gate 8 无 cursor-agent)
|
||||
- 用户反馈 Settings 上下箭头点击无效应从根因修复并补门禁
|
||||
|
||||
## 涉及文件
|
||||
|
||||
**后端**
|
||||
|
||||
- `server/infrastructure/btpanel/ssh_bootstrap.py`
|
||||
- `server/api/terminal.py`
|
||||
- `tests/test_btpanel_ssh_bootstrap.py`
|
||||
- `tests/test_terminal_quick_commands.py`
|
||||
|
||||
**前端**
|
||||
|
||||
- `frontend/src/composables/servers/useServerQuickAdd.ts`
|
||||
- `frontend/src/components/servers/ServerQuickAddDialogs.vue`
|
||||
- `frontend/src/utils/openServerTerminal.ts`
|
||||
- `frontend/src/components/terminal/TerminalTabBar.vue`
|
||||
- `frontend/src/pages/TerminalPage.vue`
|
||||
- `frontend/src/pages/ServersPage.vue`
|
||||
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
|
||||
|
||||
**门禁**
|
||||
|
||||
- `deploy/pre_deploy_check.sh` — Gate 3 本机 :8600 不可达时回退 pytest 专项
|
||||
- `docs/changelog/2026-06-29-btpanel-bootstrap-skip-reload.md`(6/29 变更说明,一并纳入本批验证)
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 宝塔 skip-reload:Nexus 中心 `deploy-production.sh` 重建 API 镜像
|
||||
- Terminal / 快捷命令:前端 `vite build` + 同步 `web/app`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py -q
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
|
||||
生产:Terminal 快速添加 → 自动开 SSH 标签;Settings 快捷命令上下箭头可调序。
|
||||
@@ -39,17 +39,17 @@
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="index === 0 || savingOrder"
|
||||
@click="moveUp(index)"
|
||||
@click.stop="moveUp(index)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-arrow-down"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="index === customCommands().length - 1 || savingOrder"
|
||||
@click="moveDown(index)"
|
||||
@click.stop="moveDown(index)"
|
||||
/>
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="openEdit(cmd)" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click="confirmRemove(cmd)" />
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click.stop="openEdit(cmd)" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click.stop="confirmRemove(cmd)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
|
||||
<v-card border>
|
||||
<v-card-title>快速添加服务器(仅 IP)</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录,首个成功即加入服务器列表。
|
||||
</p>
|
||||
<v-text-field
|
||||
v-model="quickAddForm.domain"
|
||||
label="IP 或域名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
placeholder="192.168.1.10"
|
||||
:rules="[v => !!v?.trim() || '必填']"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="quickAddForm.port"
|
||||
label="SSH 端口"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
type="number"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="quickAddForm.name"
|
||||
label="显示名称(可选)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="留空则使用 IP"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="showPollFailure" max-width="560">
|
||||
<v-card border>
|
||||
<v-card-title class="text-error">SSH 连接失败</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">已尝试全部凭据预设,均未登录成功。该服务器已加入「连接失败列表」,可稍后重试。</p>
|
||||
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
|
||||
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
|
||||
<v-list-item-title class="text-body-2">
|
||||
[{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}]
|
||||
{{ err.preset_name }} ({{ err.username }})
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useServerQuickAdd, type UseServerQuickAddOptions } from '@/composables/servers/useServerQuickAdd'
|
||||
|
||||
const props = defineProps<UseServerQuickAddOptions>()
|
||||
|
||||
const {
|
||||
showQuickAdd,
|
||||
quickAddLoading,
|
||||
quickAddForm,
|
||||
showPollFailure,
|
||||
pollFailureErrors,
|
||||
pollFailureMessage,
|
||||
openQuickAdd,
|
||||
submitQuickAdd,
|
||||
showFailureDialog,
|
||||
} = useServerQuickAdd({
|
||||
onSuccess: (res) => props.onSuccess?.(res),
|
||||
onPendingFailure: () => props.onPendingFailure?.(),
|
||||
})
|
||||
|
||||
defineExpose({ open: openQuickAdd, showFailure: showFailureDialog })
|
||||
</script>
|
||||
@@ -34,6 +34,15 @@
|
||||
新建会话
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="ml-1 shrink-0"
|
||||
prepend-icon="mdi-flash"
|
||||
@click="$emit('quick-add')"
|
||||
>
|
||||
<span class="hidden-sm-and-down">快速添加(IP)</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
@@ -59,6 +68,7 @@ defineEmits<{
|
||||
switch: [sessionId: string]
|
||||
close: [sessionId: string]
|
||||
new: []
|
||||
'quick-add': []
|
||||
'toggle-rail': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { registerServerBatchJob } from '@/composables/useScriptExecutionQueue'
|
||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import type { AddByIpResponse, PollErrorItem } from '@/types/api'
|
||||
|
||||
export interface UseServerQuickAddOptions {
|
||||
onSuccess?: (res: AddByIpResponse) => void | Promise<void>
|
||||
onPendingFailure?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function useServerQuickAdd(options: UseServerQuickAddOptions = {}) {
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
const showQuickAdd = ref(false)
|
||||
const quickAddLoading = ref(false)
|
||||
const quickAddForm = ref({ domain: '', port: 22, name: '' })
|
||||
|
||||
const showPollFailure = ref(false)
|
||||
const pollFailureErrors = ref<PollErrorItem[]>([])
|
||||
const pollFailureMessage = ref('')
|
||||
|
||||
function openQuickAdd() {
|
||||
quickAddForm.value = { domain: '', port: 22, name: '' }
|
||||
showQuickAdd.value = true
|
||||
}
|
||||
|
||||
function showFailureDialog(errors: PollErrorItem[], message?: string) {
|
||||
pollFailureErrors.value = errors
|
||||
pollFailureMessage.value = message || ''
|
||||
showPollFailure.value = true
|
||||
}
|
||||
|
||||
function registerOnboardingJob(res: AddByIpResponse) {
|
||||
if (!res.onboarding_job_id) return
|
||||
registerServerBatchJob(
|
||||
res.onboarding_job_id,
|
||||
res.onboarding_label || '新服务器初始化',
|
||||
1,
|
||||
'onboard',
|
||||
'0/1',
|
||||
'running',
|
||||
)
|
||||
showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)')
|
||||
}
|
||||
|
||||
async function submitQuickAdd() {
|
||||
const domain = quickAddForm.value.domain.trim()
|
||||
if (!domain) {
|
||||
snackbar('请填写 IP 或域名', 'error')
|
||||
return
|
||||
}
|
||||
quickAddLoading.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
domain,
|
||||
port: quickAddForm.value.port || 22,
|
||||
}
|
||||
const name = quickAddForm.value.name.trim()
|
||||
if (name) body.name = name
|
||||
|
||||
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
|
||||
if (res.success && res.server) {
|
||||
showQuickAdd.value = false
|
||||
registerOnboardingJob(res)
|
||||
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
|
||||
await options.onSuccess?.(res)
|
||||
} else {
|
||||
showQuickAdd.value = false
|
||||
showFailureDialog(res.errors || [], res.message)
|
||||
await options.onPendingFailure?.()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '添加失败'), 'error')
|
||||
} finally {
|
||||
quickAddLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showQuickAdd,
|
||||
quickAddLoading,
|
||||
quickAddForm,
|
||||
showPollFailure,
|
||||
pollFailureErrors,
|
||||
pollFailureMessage,
|
||||
openQuickAdd,
|
||||
submitQuickAdd,
|
||||
showFailureDialog,
|
||||
}
|
||||
}
|
||||
+269
-295
@@ -34,137 +34,58 @@
|
||||
<span class="text-caption text-medium-emphasis">点击顶部统计卡可切换筛选或定位</span>
|
||||
</div>
|
||||
|
||||
<ServerUnsetPathPanel
|
||||
:highlight="unsetPathFocus"
|
||||
:servers="unsetPathServers"
|
||||
:loading="unsetPathLoading"
|
||||
:total="unsetPathTotal"
|
||||
:page="unsetPathPage"
|
||||
:items-per-page="unsetPathItemsPerPage"
|
||||
:items-per-page-options="dataTablePageOptions"
|
||||
:expanded-ids="expandedUnsetIds"
|
||||
v-model:selected-items="unsetSelectedItems"
|
||||
v-model:sort-by="sortBy"
|
||||
:select-all-loading="selectAllUnsetLoading"
|
||||
:editing-path-id="editingPathId"
|
||||
:editing-path-value="editingPathValue"
|
||||
:saving-path-id="savingPathId"
|
||||
:bt-login-loading-id="btLoginLoadingId"
|
||||
:agent-diagnose-loading="agentDiagnoseLoading"
|
||||
:bt-unconfigured-count="btUnconfiguredCount"
|
||||
:bt-selected-unconfigured-count="unsetBtSelectedUnconfiguredCount"
|
||||
:bt-batch-loading="btBatchLoading"
|
||||
@refresh="loadUnsetPathServers()"
|
||||
@update:page="onUnsetPageChange"
|
||||
@update:items-per-page="onUnsetItemsPerPageChange"
|
||||
@update:expanded-ids="expandedUnsetIds = $event"
|
||||
@update:editing-path-value="editingPathValue = $event"
|
||||
@select-all-filtered="selectAllUnsetFiltered"
|
||||
@toggle-expand="toggleUnsetExpand"
|
||||
@terminal="openTerminal"
|
||||
@bt-login="openBtLogin($event.id)"
|
||||
@files="openFiles"
|
||||
@edit="editServer"
|
||||
@edit-path="startEditPath"
|
||||
@save-path="saveTargetPath"
|
||||
@cancel-edit-path="cancelEditPath"
|
||||
@batch-category="openBatchCategory('unset')"
|
||||
@health-check="batchHealthCheck('unset')"
|
||||
@detect-path="openDetectPathConfirm('unset')"
|
||||
@agent-diagnose="batchAgentDiagnose('unset')"
|
||||
@bt-bootstrap-all="openBtBatchConfirm('unset', 'all')"
|
||||
@bt-bootstrap-selected="openBtBatchConfirm('unset', 'selected')"
|
||||
@install-agent="batchInstallAgent('unset')"
|
||||
@upgrade-agent="batchUpgradeAgent('unset')"
|
||||
@uninstall-agent="batchUninstallAgent('unset')"
|
||||
@batch-delete="confirmBatchDelete('unset')"
|
||||
@clear-selection="unsetSelectedItems = []"
|
||||
/>
|
||||
<v-card elevation="0" rounded="lg" class="my-5" border>
|
||||
<v-card-text class="py-3">
|
||||
<ServerCategoryFilterBar
|
||||
:categories="categoryList"
|
||||
:category-filter="categoryFilter"
|
||||
:total-count="totalCount"
|
||||
:categories-loading="categoriesLoading"
|
||||
:search-active="!!search.trim()"
|
||||
@select="selectCategory"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Server Table -->
|
||||
<v-card elevation="0" rounded="lg" class="my-5" border>
|
||||
<v-card-title class="d-flex align-center">
|
||||
服务器列表
|
||||
<v-spacer />
|
||||
<v-combobox
|
||||
v-model="searchDraft"
|
||||
:items="serverSearchHistory"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="搜索名称/IP(含未设路径)"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
:auto-select-first="false"
|
||||
style="max-width: 240px"
|
||||
variant="outlined"
|
||||
placeholder="输入后回车,或从历史选择"
|
||||
@update:model-value="onSearchComboboxUpdate"
|
||||
@keydown.enter.prevent="commitSearch"
|
||||
<v-card-title class="server-panel-card-title d-flex align-center flex-wrap ga-2">
|
||||
<span class="text-h6 font-weight-medium text-no-wrap">服务器列表</span>
|
||||
<ServerListToolbarActions
|
||||
v-model:search-draft="searchDraft"
|
||||
:search-history="serverSearchHistory"
|
||||
:detect-path-disabled="selectedIds.size === 0"
|
||||
show-view-toggle
|
||||
v-model:view-mode="viewMode"
|
||||
@commit-search="commitSearch"
|
||||
@search-combobox-update="onSearchComboboxUpdate"
|
||||
@add="openAddServer"
|
||||
@quick-add="openQuickAdd"
|
||||
@batch-add="openBatchAdd"
|
||||
@credentials="showCredentialsDialog = true"
|
||||
@detect-path="openDetectPathConfirm('main')"
|
||||
/>
|
||||
<v-btn color="primary" variant="flat" class="ml-3" prepend-icon="mdi-plus" size="small" @click="openAddServer">
|
||||
添加
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
|
||||
快速添加(IP)
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
|
||||
批量添加
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-key-outline" size="small" @click="showCredentialsDialog = true">
|
||||
凭据
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
class="ml-2"
|
||||
prepend-icon="mdi-folder-search"
|
||||
size="small"
|
||||
:disabled="selectedIds.size === 0"
|
||||
title="请先勾选至少一台服务器"
|
||||
@click="openDetectPathConfirm('main')"
|
||||
>
|
||||
检测路径
|
||||
</v-btn>
|
||||
<v-btn-toggle v-model="viewMode" mandatory density="compact" class="ml-2" color="primary" variant="outlined" divided>
|
||||
<v-btn value="table" size="small" prepend-icon="mdi-table">列表</v-btn>
|
||||
<v-btn value="group" size="small" prepend-icon="mdi-folder-multiple">分类</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pt-0 pb-2">
|
||||
<v-chip
|
||||
v-if="search.trim()"
|
||||
size="small"
|
||||
<v-alert
|
||||
v-if="showUnsetPathHint"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
label
|
||||
class="mb-2"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
全库搜索中(含未设路径与连接失败;已忽略分类/在线筛选)
|
||||
</v-chip>
|
||||
该分类「{{ categoryDisplayLabel(categoryFilter) }}」共 {{ unsetPathTotal }} 台未设路径,已在下方区块展示
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
@click="scrollToUnsetPathPanel"
|
||||
>
|
||||
定位
|
||||
</v-btn>
|
||||
</v-alert>
|
||||
<div class="d-flex align-center flex-wrap ga-2">
|
||||
<span class="text-caption text-medium-emphasis mr-1">分类筛选</span>
|
||||
<v-chip
|
||||
:color="categoryFilter === '' ? 'primary' : undefined"
|
||||
:variant="categoryFilter === '' ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
label
|
||||
@click="selectCategory('')"
|
||||
>
|
||||
全部 {{ totalCount }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="cat in categoryList"
|
||||
:key="cat.name"
|
||||
:color="categoryFilter === cat.name ? 'primary' : undefined"
|
||||
:variant="categoryFilter === cat.name ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
label
|
||||
@click="selectCategory(cat.name)"
|
||||
>
|
||||
{{ cat.label }} {{ cat.count }}
|
||||
</v-chip>
|
||||
<v-progress-circular v-if="categoriesLoading" indeterminate size="16" width="2" />
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small"
|
||||
@@ -221,7 +142,7 @@
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:expanded="expandedMainIdsModel"
|
||||
show-expand
|
||||
class="server-main-table"
|
||||
class="server-data-table"
|
||||
@update:page="onMainPageChange"
|
||||
@update:items-per-page="onMainItemsPerPageChange"
|
||||
>
|
||||
@@ -233,15 +154,13 @@
|
||||
</template>
|
||||
|
||||
<template #item.name="{ item }">
|
||||
<div
|
||||
class="d-flex align-center cursor-pointer text-primary"
|
||||
@click.stop="toggleMainExpand(item)"
|
||||
>
|
||||
<v-icon size="small" class="mr-1">
|
||||
{{ expandedMainIds.includes(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
|
||||
</v-icon>
|
||||
<span class="font-weight-medium">{{ item.name }}</span>
|
||||
</div>
|
||||
<ServerNameWithRemark
|
||||
:name="item.name"
|
||||
:remark="item.description"
|
||||
:expanded="expandedMainIds.includes(item.id)"
|
||||
@toggle="toggleMainExpand(item)"
|
||||
@edit-remark="startEditRemark(item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item.domain="{ item }">
|
||||
@@ -269,24 +188,24 @@
|
||||
</template>
|
||||
|
||||
<template #item.target_path="{ item }">
|
||||
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1" @click.stop>
|
||||
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1 server-path-cell" @click.stop>
|
||||
<v-text-field
|
||||
v-model="editingPathValue"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
placeholder="/www/wwwroot"
|
||||
style="max-width: 140px"
|
||||
class="flex-grow-1"
|
||||
style="min-width: 140px"
|
||||
@keyup.enter="saveTargetPath(item.id)"
|
||||
@keyup.esc="cancelEditPath"
|
||||
/>
|
||||
<v-btn icon="mdi-check" size="x-small" variant="text" color="primary" :loading="savingPathId === item.id" @click="saveTargetPath(item.id)" />
|
||||
<v-btn icon="mdi-close" size="x-small" variant="text" @click="cancelEditPath" />
|
||||
</div>
|
||||
<div v-else class="d-flex align-center ga-1">
|
||||
<div v-else class="d-flex align-center ga-1 server-path-cell">
|
||||
<span
|
||||
class="text-caption text-medium-emphasis text-truncate d-inline-block"
|
||||
style="max-width: 140px"
|
||||
class="text-caption text-medium-emphasis server-path-cell__text text-truncate d-inline-block flex-grow-1"
|
||||
:title="item.target_path || '未配置'"
|
||||
>
|
||||
{{ item.target_path?.trim() || '—' }}
|
||||
@@ -350,6 +269,7 @@
|
||||
:active="expandedMainIds.includes(item.id)"
|
||||
:agent-action-loading="agentActionLoadingId === item.id"
|
||||
@edit-path="startEditPath(item)"
|
||||
@edit-remark="startEditRemark(item)"
|
||||
@install-agent="installAgentOne(item)"
|
||||
@upgrade-agent="upgradeAgentOne(item)"
|
||||
@diagnose-agent="openAgentDiagnose(item)"
|
||||
@@ -372,7 +292,7 @@
|
||||
暂无服务器数据
|
||||
</div>
|
||||
<v-expansion-panels v-else v-model="expandedCategories" multiple variant="accordion" class="servers-category-panels">
|
||||
<v-expansion-panel v-for="group in groupedServers" :key="group.category">
|
||||
<v-expansion-panel v-for="group in groupedServers" :key="group.category" :value="group.category">
|
||||
<v-expansion-panel-title>
|
||||
<div class="d-flex align-center ga-2 flex-grow-1">
|
||||
<v-chip color="primary" variant="tonal" size="small" label>{{ group.label }}</v-chip>
|
||||
@@ -446,6 +366,65 @@
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<ServerUnsetPathPanel
|
||||
v-model:search-draft="searchDraft"
|
||||
v-model:view-mode="viewMode"
|
||||
:search-history="serverSearchHistory"
|
||||
:highlight="unsetPathFocus"
|
||||
:servers="unsetPathServers"
|
||||
:loading="unsetPathLoading"
|
||||
:total="unsetPathTotal"
|
||||
:page="unsetPathPage"
|
||||
:items-per-page="unsetPathItemsPerPage"
|
||||
:items-per-page-options="dataTablePageOptions"
|
||||
:expanded-ids="expandedUnsetIds"
|
||||
v-model:selected-items="unsetSelectedItems"
|
||||
v-model:sort-by="unsetSortBy"
|
||||
:select-all-loading="selectAllUnsetLoading"
|
||||
:editing-path-id="editingPathId"
|
||||
:editing-path-value="editingPathValue"
|
||||
:saving-path-id="savingPathId"
|
||||
:bt-login-loading-id="btLoginLoadingId"
|
||||
:agent-diagnose-loading="agentDiagnoseLoading"
|
||||
:bt-unconfigured-count="btUnconfiguredCount"
|
||||
:bt-selected-unconfigured-count="unsetBtSelectedUnconfiguredCount"
|
||||
:bt-batch-loading="btBatchLoading"
|
||||
:agent-action-loading-id="agentActionLoadingId"
|
||||
@refresh="loadUnsetPathServers()"
|
||||
@update:page="onUnsetPageChange"
|
||||
@update:items-per-page="onUnsetItemsPerPageChange"
|
||||
@update:expanded-ids="expandedUnsetIds = $event"
|
||||
@update:editing-path-value="editingPathValue = $event"
|
||||
@edit-remark="startEditRemark"
|
||||
@select-all-filtered="selectAllUnsetFiltered"
|
||||
@toggle-expand="toggleUnsetExpand"
|
||||
@terminal="openTerminal"
|
||||
@bt-login="openBtLogin($event.id)"
|
||||
@files="openFiles"
|
||||
@edit="editServer"
|
||||
@edit-path="startEditPath"
|
||||
@save-path="saveTargetPath"
|
||||
@cancel-edit-path="cancelEditPath"
|
||||
@install-agent="onUnsetInstallAgent"
|
||||
@upgrade-agent="onUnsetUpgradeAgent"
|
||||
@diagnose-agent="openAgentDiagnose"
|
||||
@batch-category="openBatchCategory('unset')"
|
||||
@health-check="batchHealthCheck('unset')"
|
||||
@detect-path="openDetectPathConfirm('unset')"
|
||||
@agent-diagnose="batchAgentDiagnose('unset')"
|
||||
@bt-bootstrap-all="openBtBatchConfirm('unset', 'all')"
|
||||
@bt-bootstrap-selected="openBtBatchConfirm('unset', 'selected')"
|
||||
@uninstall-agent="batchUninstallAgent('unset')"
|
||||
@batch-delete="confirmBatchDelete('unset')"
|
||||
@clear-selection="unsetSelectedItems = []"
|
||||
@commit-search="commitSearch"
|
||||
@search-combobox-update="onSearchComboboxUpdate"
|
||||
@add-server="openAddServer"
|
||||
@quick-add="openQuickAdd"
|
||||
@batch-add="openBatchAdd"
|
||||
@open-credentials="showCredentialsDialog = true"
|
||||
/>
|
||||
|
||||
<!-- Pending / failed SSH connections -->
|
||||
<v-card id="pending-servers-panel" elevation="0" rounded="lg" class="my-5" border>
|
||||
<v-card-title class="d-flex align-center">
|
||||
@@ -505,6 +484,15 @@
|
||||
@save="saveServerForm"
|
||||
/>
|
||||
|
||||
<!-- Remark edit -->
|
||||
<ServerRemarkDialog
|
||||
v-model="showRemarkDialog"
|
||||
v-model:model-value-text="editingRemarkValue"
|
||||
:server-name="remarkEditServerName"
|
||||
:saving="savingRemarkId != null"
|
||||
@save="saveRemarkFromDialog"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirm -->
|
||||
<v-dialog v-model="showDelete" max-width="400">
|
||||
<v-card border>
|
||||
@@ -518,70 +506,11 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Quick add by IP -->
|
||||
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
|
||||
<v-card border>
|
||||
<v-card-title>快速添加服务器(仅 IP)</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录,首个成功即加入服务器列表。
|
||||
</p>
|
||||
<v-text-field
|
||||
v-model="quickAddForm.domain"
|
||||
label="IP 或域名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
placeholder="192.168.1.10"
|
||||
:rules="[v => !!v?.trim() || '必填']"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="quickAddForm.port"
|
||||
label="SSH 端口"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
type="number"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="quickAddForm.name"
|
||||
label="显示名称(可选)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="留空则使用 IP"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Poll failure detail -->
|
||||
<v-dialog v-model="showPollFailure" max-width="560">
|
||||
<v-card border>
|
||||
<v-card-title class="text-error">SSH 连接失败</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">已尝试全部凭据预设,均未登录成功。该服务器已加入「连接失败列表」,可稍后重试。</p>
|
||||
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
|
||||
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
|
||||
<v-list-item-title class="text-body-2">
|
||||
[{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}] {{ err.preset_name }} ({{ err.username }})
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<ServerQuickAddDialogs
|
||||
ref="quickAddDialogsRef"
|
||||
:on-success="onQuickAddSuccess"
|
||||
:on-pending-failure="loadPendingServers"
|
||||
/>
|
||||
|
||||
<!-- Delete pending confirm -->
|
||||
<v-dialog v-model="showDeletePending" max-width="400">
|
||||
@@ -605,8 +534,8 @@
|
||||
将对选中的 <strong>{{ scopedSelectedCount }}</strong> 台服务器执行 SSH 检测。
|
||||
</p>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
在远程 <code>/www/wwwroot</code> 下搜索 <code>workerman.bat</code>,找到后将所在目录写入各服务器的
|
||||
<strong>target_path</strong>(找到第一个即停止)。
|
||||
在 <code>/www/wwwroot</code> 下搜索 <code>workerman.bat</code>,取其所在目录写入
|
||||
<strong>target_path</strong>。
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
@@ -753,20 +682,21 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, type Ref }
|
||||
import { usePageAutoRefresh } from '@/composables/usePageAutoRefresh'
|
||||
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
|
||||
import { CACHE_KEYS, cachedFetch, invalidateCachedQuery } from '@/composables/useCachedQuery'
|
||||
import { toServerApiSort } from '@/utils/serverTableSort'
|
||||
import { toServerApiSort, type TableSortItem } from '@/utils/serverTableSort'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useServerPagination } from '@/composables/useServerPagination'
|
||||
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
|
||||
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
|
||||
import { categoryDisplayLabel, categoryFilterQueryParams, groupServersByCategory, useServerCategories, CATEGORY_MERGE_GROUPS } from '@/composables/useServerCategories'
|
||||
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
|
||||
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
||||
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, ServerApiItem } from '@/types/api'
|
||||
import { normalizeServerIds } from '@/utils/serverSelection'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import { resolveServerSiteHost, resolveServerSiteUrl } from '@/utils/serverSiteUrl'
|
||||
import { openServerTerminal } from '@/utils/openServerTerminal'
|
||||
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
|
||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||
import StatCardsRow from '@/components/StatCardsRow.vue'
|
||||
@@ -776,7 +706,12 @@ import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
|
||||
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
|
||||
import ServerTableRowActions from '@/components/servers/ServerTableRowActions.vue'
|
||||
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
|
||||
import ServerNameWithRemark from '@/components/servers/ServerNameWithRemark.vue'
|
||||
import ServerRemarkDialog from '@/components/servers/ServerRemarkDialog.vue'
|
||||
import ServerUnsetPathPanel from '@/components/servers/ServerUnsetPathPanel.vue'
|
||||
import ServerQuickAddDialogs from '@/components/servers/ServerQuickAddDialogs.vue'
|
||||
import ServerListToolbarActions from '@/components/servers/ServerListToolbarActions.vue'
|
||||
import ServerCategoryFilterBar from '@/components/servers/ServerCategoryFilterBar.vue'
|
||||
import { SERVER_DATA_TABLE_HEADERS } from '@/constants/serverTableHeaders'
|
||||
import CredentialsDialog from '@/components/credentials/CredentialsDialog.vue'
|
||||
import AgentDiagnoseDialog from '@/components/servers/AgentDiagnoseDialog.vue'
|
||||
@@ -932,6 +867,9 @@ const {
|
||||
loadAllServers, listQueryParams,
|
||||
} = useServerPagination({ targetPathUnset: false })
|
||||
|
||||
/** 未设路径区块独立排序:默认最新添加在上 */
|
||||
const unsetSortBy = ref<TableSortItem[]>([{ key: 'created_at', order: 'desc' }])
|
||||
|
||||
const { items: serverSearchHistory, record: recordServerSearch, loadHistoryOnly: loadServerSearchHistory } =
|
||||
useServerSearchHistory()
|
||||
|
||||
@@ -1045,12 +983,31 @@ function openBtBatchConfirm(scope: BatchScope, mode: 'all' | 'selected') {
|
||||
|
||||
const scopedSelectedCount = computed(() => batchSelection(batchScope.value).ids.value.size)
|
||||
|
||||
/** 未设路径区块独立列表:不受顶部分类 chip 过滤,避免改分类后机器从面板消失 */
|
||||
function unsetListQueryParams(): Record<string, unknown> {
|
||||
/** 主列表 / 未设路径 / 分类视图共用的分类、在线、搜索(不含路径分区与排序) */
|
||||
function serverScopeFilters(): Record<string, unknown> {
|
||||
const q = String(search.value ?? '').trim()
|
||||
return {
|
||||
search: search.value || undefined,
|
||||
search: q || undefined,
|
||||
...(!q ? categoryFilterQueryParams(categoryFilter.value) : {}),
|
||||
...(!q && isOnlineFilter.value !== null ? { is_online: isOnlineFilter.value } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 未设路径区块:共用筛选,固定 target_path_unset=true,排序独立(默认 created_at desc) */
|
||||
function unsetListQueryParams(): Record<string, unknown> {
|
||||
const q = String(search.value ?? '').trim()
|
||||
return {
|
||||
...serverScopeFilters(),
|
||||
...toServerApiSort(unsetSortBy.value),
|
||||
...(!q ? { target_path_unset: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类视图:展示当前筛选范围内全部机器(不按路径分区) */
|
||||
function groupListQueryParams(): Record<string, unknown> {
|
||||
return {
|
||||
...serverScopeFilters(),
|
||||
...toServerApiSort(sortBy.value),
|
||||
target_path_unset: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,7 +1121,13 @@ async function loadServers(silent = false) {
|
||||
async function loadGroupServers(silent = false) {
|
||||
if (!silent) groupLoading.value = true
|
||||
try {
|
||||
groupServers.value = await loadAllServers()
|
||||
const res = await fetchPagePerPage<ServerApiItem>(
|
||||
'/servers/',
|
||||
groupListQueryParams(),
|
||||
1,
|
||||
-1,
|
||||
)
|
||||
groupServers.value = res.items
|
||||
expandedCategories.value = groupedServers.value.map(g => g.category)
|
||||
} catch {
|
||||
if (!silent) {
|
||||
@@ -1188,25 +1151,7 @@ async function refreshServersSilent() {
|
||||
|
||||
usePageAutoRefresh(refreshServersSilent, 30_000)
|
||||
|
||||
const groupedServers = computed(() => {
|
||||
const groups: Record<string, ServerApiItem[]> = {}
|
||||
for (const s of groupServers.value) {
|
||||
const cat = s.category?.trim() || 'uncategorized'
|
||||
if (!groups[cat]) groups[cat] = []
|
||||
groups[cat].push(s)
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.map(([category, list]) => ({
|
||||
category,
|
||||
label: categoryDisplayLabel(category),
|
||||
servers: list,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.category === 'uncategorized') return 1
|
||||
if (b.category === 'uncategorized') return -1
|
||||
return a.label.localeCompare(b.label, 'zh')
|
||||
})
|
||||
})
|
||||
const groupedServers = computed(() => groupServersByCategory(groupServers.value))
|
||||
|
||||
function selectCategory(name: string) {
|
||||
onCategoryChange(name)
|
||||
@@ -1254,10 +1199,13 @@ function toggleGroupAll(groupServers: ServerApiItem[]) {
|
||||
|
||||
watch(categoryFilter, () => {
|
||||
selectedItems.value = []
|
||||
unsetSelectedItems.value = []
|
||||
expandedMainIds.value = []
|
||||
expandedUnsetIds.value = []
|
||||
unsetPathPage.value = 1
|
||||
void loadServers()
|
||||
})
|
||||
watch(sortBy, () => {
|
||||
watch(isOnlineFilter, () => {
|
||||
selectedItems.value = []
|
||||
unsetSelectedItems.value = []
|
||||
expandedMainIds.value = []
|
||||
@@ -1265,6 +1213,17 @@ watch(sortBy, () => {
|
||||
unsetPathPage.value = 1
|
||||
if (viewMode.value === 'group') void loadGroupServers()
|
||||
void loadUnsetPathServers()
|
||||
})
|
||||
watch(sortBy, () => {
|
||||
selectedItems.value = []
|
||||
expandedMainIds.value = []
|
||||
if (viewMode.value === 'group') void loadGroupServers()
|
||||
}, { deep: true })
|
||||
watch(unsetSortBy, () => {
|
||||
unsetSelectedItems.value = []
|
||||
expandedUnsetIds.value = []
|
||||
unsetPathPage.value = 1
|
||||
void loadUnsetPathServers()
|
||||
}, { deep: true })
|
||||
watch(search, () => {
|
||||
selectedItems.value = []
|
||||
@@ -1297,15 +1256,10 @@ const deleting = ref(false)
|
||||
const deletingServer = ref<ServerApiItem | null>(null)
|
||||
|
||||
// ── Quick add by IP + pending failure list ──
|
||||
const showQuickAdd = ref(false)
|
||||
const quickAddLoading = ref(false)
|
||||
const quickAddForm = ref({ domain: '', port: 22, name: '' })
|
||||
const quickAddDialogsRef = ref<InstanceType<typeof ServerQuickAddDialogs> | null>(null)
|
||||
const pendingServers = ref<PendingServerItem[]>([])
|
||||
const pendingLoading = ref(false)
|
||||
const retryingId = ref<number | null>(null)
|
||||
const showPollFailure = ref(false)
|
||||
const pollFailureErrors = ref<PollErrorItem[]>([])
|
||||
const pollFailureMessage = ref('')
|
||||
const showDeletePending = ref(false)
|
||||
const deletingPending = ref<PendingServerItem | null>(null)
|
||||
const deletingPendingLoading = ref(false)
|
||||
@@ -1320,14 +1274,13 @@ const pendingHeaders = [
|
||||
]
|
||||
|
||||
function openQuickAdd() {
|
||||
quickAddForm.value = { domain: '', port: 22, name: '' }
|
||||
showQuickAdd.value = true
|
||||
quickAddDialogsRef.value?.open()
|
||||
}
|
||||
|
||||
function showFailureDialog(errors: PollErrorItem[], message?: string) {
|
||||
pollFailureErrors.value = errors
|
||||
pollFailureMessage.value = message || ''
|
||||
showPollFailure.value = true
|
||||
async function onQuickAddSuccess(res: AddByIpResponse) {
|
||||
await Promise.all([loadServers(), loadStats()])
|
||||
const id = res.server?.id
|
||||
if (id != null) openServerTerminal(router, id)
|
||||
}
|
||||
|
||||
async function loadPendingServers(silent = false) {
|
||||
@@ -1347,40 +1300,6 @@ async function loadPendingServers(silent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQuickAdd() {
|
||||
const domain = quickAddForm.value.domain.trim()
|
||||
if (!domain) {
|
||||
snackbar('请填写 IP 或域名', 'error')
|
||||
return
|
||||
}
|
||||
quickAddLoading.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
domain,
|
||||
port: quickAddForm.value.port || 22,
|
||||
}
|
||||
const name = quickAddForm.value.name.trim()
|
||||
if (name) body.name = name
|
||||
|
||||
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
|
||||
if (res.success && res.server) {
|
||||
showQuickAdd.value = false
|
||||
registerOnboardingJob(res.onboarding_job_id, res.onboarding_label, 1)
|
||||
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
|
||||
loadServers()
|
||||
loadStats()
|
||||
} else {
|
||||
showQuickAdd.value = false
|
||||
showFailureDialog(res.errors || [], res.message)
|
||||
loadPendingServers()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '添加失败'), 'error')
|
||||
} finally {
|
||||
quickAddLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPending(item: PendingServerItem) {
|
||||
retryingId.value = item.id
|
||||
try {
|
||||
@@ -1392,7 +1311,7 @@ async function retryPending(item: PendingServerItem) {
|
||||
loadStats()
|
||||
loadPendingServers()
|
||||
} else {
|
||||
showFailureDialog(res.errors || [], res.message)
|
||||
quickAddDialogsRef.value?.showFailure(res.errors || [], res.message)
|
||||
loadPendingServers()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
@@ -1485,6 +1404,17 @@ const statusFilterLabel = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const showUnsetPathHint = computed(() => {
|
||||
const q = String(search.value ?? '').trim()
|
||||
return (
|
||||
!q
|
||||
&& !!categoryFilter.value
|
||||
&& !loading.value
|
||||
&& total.value === 0
|
||||
&& unsetPathTotal.value > 0
|
||||
)
|
||||
})
|
||||
|
||||
function scrollToUnsetPathPanel() {
|
||||
void nextTick(() => {
|
||||
document.getElementById('unset-path-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
@@ -1678,6 +1608,16 @@ async function upgradeAgentOne(item: ServerApiItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function onUnsetInstallAgent(item?: ServerApiItem) {
|
||||
if (item) void installAgentOne(item)
|
||||
else void batchInstallAgent('unset')
|
||||
}
|
||||
|
||||
function onUnsetUpgradeAgent(item?: ServerApiItem) {
|
||||
if (item) void upgradeAgentOne(item)
|
||||
else void batchUpgradeAgent('unset')
|
||||
}
|
||||
|
||||
function editServer(item: ServerApiItem) {
|
||||
void openEditServerDialog(item)
|
||||
}
|
||||
@@ -1693,9 +1633,15 @@ const batchCategoryValue = ref('')
|
||||
const batchCategoryLoading = ref(false)
|
||||
|
||||
const batchCategoryOptions = computed(() => {
|
||||
const mergedIds = new Set(CATEGORY_MERGE_GROUPS.map(g => g.id))
|
||||
const names = categoryList.value
|
||||
.filter(c => c.name !== 'uncategorized')
|
||||
.filter(c => c.name !== 'uncategorized' && !mergedIds.has(c.name))
|
||||
.map(c => c.label)
|
||||
for (const g of CATEGORY_MERGE_GROUPS) {
|
||||
for (const m of g.members) {
|
||||
if (!names.includes(m)) names.push(m)
|
||||
}
|
||||
}
|
||||
if (categoryOptions.value.length) {
|
||||
for (const c of categoryOptions.value) {
|
||||
if (c && !names.includes(c)) names.push(c)
|
||||
@@ -1739,6 +1685,12 @@ const editingPathId = ref<number | null>(null)
|
||||
const editingPathValue = ref('')
|
||||
const savingPathId = ref<number | null>(null)
|
||||
|
||||
const editingRemarkId = ref<number | null>(null)
|
||||
const editingRemarkValue = ref('')
|
||||
const savingRemarkId = ref<number | null>(null)
|
||||
const showRemarkDialog = ref(false)
|
||||
const remarkEditServerName = ref('')
|
||||
|
||||
function openDetectPathConfirm(scope: BatchScope) {
|
||||
batchScope.value = scope
|
||||
const { ids } = batchSelection(scope)
|
||||
@@ -1827,6 +1779,7 @@ async function batchUninstallAgent(scope: BatchScope) {
|
||||
}
|
||||
|
||||
function startEditPath(item: ServerApiItem) {
|
||||
cancelEditRemark()
|
||||
editingPathId.value = item.id
|
||||
editingPathValue.value = item.target_path?.trim() || '/www/wwwroot'
|
||||
}
|
||||
@@ -1861,6 +1814,39 @@ async function saveTargetPath(serverId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function startEditRemark(item: ServerApiItem) {
|
||||
cancelEditPath()
|
||||
editingRemarkId.value = item.id
|
||||
editingRemarkValue.value = item.description?.trim() || ''
|
||||
remarkEditServerName.value = item.name
|
||||
showRemarkDialog.value = true
|
||||
}
|
||||
|
||||
function cancelEditRemark() {
|
||||
showRemarkDialog.value = false
|
||||
editingRemarkId.value = null
|
||||
editingRemarkValue.value = ''
|
||||
remarkEditServerName.value = ''
|
||||
savingRemarkId.value = null
|
||||
}
|
||||
|
||||
async function saveRemarkFromDialog() {
|
||||
const serverId = editingRemarkId.value
|
||||
if (serverId == null) return
|
||||
savingRemarkId.value = serverId
|
||||
try {
|
||||
const description = editingRemarkValue.value.trim() || null
|
||||
await http.put(`/servers/${serverId}`, { description })
|
||||
snackbar('备注已更新')
|
||||
cancelEditRemark()
|
||||
await Promise.all([loadServers(), loadUnsetPathServers()])
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '保存失败'), 'error')
|
||||
} finally {
|
||||
savingRemarkId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchDelete(scope: BatchScope) {
|
||||
batchScope.value = scope
|
||||
const { items, ids } = batchSelection(scope)
|
||||
@@ -1995,19 +1981,6 @@ usePageActivateRefresh((silent) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-main-table :deep(.v-data-table__td--expand-column),
|
||||
.server-main-table :deep(.v-data-table-column--expand),
|
||||
.server-main-table :deep(th.v-data-table__th--expand) {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.server-main-table :deep(tr.v-data-table__expanded__row > td) {
|
||||
background: transparent !important;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.servers-category-panels :deep(.v-expansion-panel-text__wrapper) {
|
||||
background: color-mix(in srgb, rgb(var(--v-theme-surface)) 95%, rgb(var(--v-theme-on-surface)));
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
@@ -2018,7 +1991,8 @@ usePageActivateRefresh((silent) => {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.site-link:hover {
|
||||
text-decoration: underline;
|
||||
.server-panel-card-title {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -25,6 +25,7 @@
|
||||
@switch="switchTab"
|
||||
@close="closeTab"
|
||||
@new="newSession()"
|
||||
@quick-add="openTerminalQuickAdd"
|
||||
@toggle-rail="toggleServerRail"
|
||||
/>
|
||||
|
||||
@@ -94,6 +95,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ServerQuickAddDialogs
|
||||
ref="quickAddDialogsRef"
|
||||
:on-success="onQuickAddSuccess"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="showServerDialog" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>选择服务器</v-card-title>
|
||||
@@ -128,6 +134,8 @@ import TerminalCmdBar from '@/components/terminal/TerminalCmdBar.vue'
|
||||
import TerminalQuickBar from '@/components/terminal/TerminalQuickBar.vue'
|
||||
import TerminalServerPicker from '@/components/terminal/TerminalServerPicker.vue'
|
||||
import TerminalContextMenu from '@/components/terminal/TerminalContextMenu.vue'
|
||||
import ServerQuickAddDialogs from '@/components/servers/ServerQuickAddDialogs.vue'
|
||||
import type { AddByIpResponse } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'TerminalPage' })
|
||||
|
||||
@@ -189,6 +197,19 @@ function toggleServerRail() {
|
||||
|
||||
const areaRef = ref<InstanceType<typeof TerminalArea> | null>(null)
|
||||
const cmdBarRef = ref<InstanceType<typeof TerminalCmdBar> | null>(null)
|
||||
const quickAddDialogsRef = ref<InstanceType<typeof ServerQuickAddDialogs> | null>(null)
|
||||
|
||||
function openTerminalQuickAdd() {
|
||||
quickAddDialogsRef.value?.open()
|
||||
}
|
||||
|
||||
async function onQuickAddSuccess(res: AddByIpResponse) {
|
||||
await loadServers()
|
||||
const id = res.server?.id
|
||||
if (id == null) return
|
||||
await nextTick()
|
||||
await newSession(id)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => areaRef.value?.containerRef,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
/** Navigate to WebSSH terminal and open a session for the server. */
|
||||
export function openServerTerminal(router: Router, serverId: number) {
|
||||
void router.push({
|
||||
path: '/terminal',
|
||||
query: { server_id: String(serverId) },
|
||||
})
|
||||
}
|
||||
+34
-34
@@ -125,40 +125,6 @@ async def create_quick_command(
|
||||
return _qc_to_dict(qc)
|
||||
|
||||
|
||||
@router.put("/quick-commands/{id}", response_model=dict)
|
||||
async def update_quick_command(
|
||||
id: int,
|
||||
payload: QuickCommandUpdate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a custom quick command (built-in commands cannot be edited)"""
|
||||
result = await db.execute(select(QuickCommand).where(QuickCommand.id == id))
|
||||
qc = result.scalar_one_or_none()
|
||||
if not qc:
|
||||
raise HTTPException(status_code=404, detail="快捷命令不存在")
|
||||
if qc.is_builtin:
|
||||
raise HTTPException(status_code=403, detail="系统内置命令不可编辑")
|
||||
|
||||
if payload.name is not None:
|
||||
qc.name = payload.name
|
||||
if payload.cmd is not None:
|
||||
qc.cmd = payload.cmd
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(qc)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="update_quick_command",
|
||||
target_type="quick_command", target_id=id,
|
||||
detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
return _qc_to_dict(qc)
|
||||
|
||||
|
||||
@router.put("/quick-commands/reorder", response_model=dict)
|
||||
async def reorder_quick_commands(
|
||||
payload: QuickCommandReorder,
|
||||
@@ -198,6 +164,40 @@ async def reorder_quick_commands(
|
||||
return {"items": [_qc_to_dict(qc) for qc in result.scalars().all()]}
|
||||
|
||||
|
||||
@router.put("/quick-commands/{id}", response_model=dict)
|
||||
async def update_quick_command(
|
||||
id: int,
|
||||
payload: QuickCommandUpdate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a custom quick command (built-in commands cannot be edited)"""
|
||||
result = await db.execute(select(QuickCommand).where(QuickCommand.id == id))
|
||||
qc = result.scalar_one_or_none()
|
||||
if not qc:
|
||||
raise HTTPException(status_code=404, detail="快捷命令不存在")
|
||||
if qc.is_builtin:
|
||||
raise HTTPException(status_code=403, detail="系统内置命令不可编辑")
|
||||
|
||||
if payload.name is not None:
|
||||
qc.name = payload.name
|
||||
if payload.cmd is not None:
|
||||
qc.cmd = payload.cmd
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(qc)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="update_quick_command",
|
||||
target_type="quick_command", target_id=id,
|
||||
detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
return _qc_to_dict(qc)
|
||||
|
||||
|
||||
@router.delete("/quick-commands/{id}", status_code=204)
|
||||
async def delete_quick_command(
|
||||
id: int,
|
||||
|
||||
@@ -34,3 +34,42 @@ async def test_create_quick_command_stores_trimmed_cmd_without_cr(
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert delete.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_reorder_quick_commands_route_not_shadowed_by_id(
|
||||
integration_env, api_client, auth_headers
|
||||
):
|
||||
"""PUT /quick-commands/reorder must not match /quick-commands/{id} (id=reorder → 422)."""
|
||||
ids: list[int] = []
|
||||
for i in range(2):
|
||||
create = await api_client.post(
|
||||
"/api/terminal/quick-commands",
|
||||
headers=auth_headers,
|
||||
json={"name": f"qc-order-{i}", "cmd": f"echo {i}"},
|
||||
)
|
||||
assert create.status_code == 201, create.text
|
||||
ids.append(create.json()["id"])
|
||||
|
||||
listed = await api_client.get("/api/terminal/quick-commands", headers=auth_headers)
|
||||
custom = [c for c in listed.json() if not c["is_builtin"] and c["id"] in ids]
|
||||
assert len(custom) == 2
|
||||
first_id, second_id = custom[0]["id"], custom[1]["id"]
|
||||
|
||||
reorder = await api_client.put(
|
||||
"/api/terminal/quick-commands/reorder",
|
||||
headers=auth_headers,
|
||||
json={"ids": [second_id, first_id]},
|
||||
)
|
||||
assert reorder.status_code == 200, reorder.text
|
||||
items = reorder.json()["items"]
|
||||
custom_after = [c for c in items if not c["is_builtin"] and c["id"] in ids]
|
||||
assert [c["id"] for c in custom_after] == [second_id, first_id]
|
||||
|
||||
for cmd_id in ids:
|
||||
delete = await api_client.delete(
|
||||
f"/api/terminal/quick-commands/{cmd_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert delete.status_code == 204
|
||||
|
||||
Reference in New Issue
Block a user