feat(btpanel): 服务器列表批量 SSH 获取 API
支持全部未配置与多选批量引导;未配置 API 时禁用一键登录。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Changelog — 宝塔批量 SSH 获取 API(2026-06-21)
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
服务器列表页增加批量/全量「SSH 获取 API」;未配置 API 时禁用一键登录(避免 404 临时链)。
|
||||
|
||||
## 动机
|
||||
|
||||
- 2000+ 子机需先写入 `api.json` 才能稳定一键登录
|
||||
- 原批量入口仅在连接配置页,不易发现
|
||||
- `all_unconfigured` 服务端解析未配置列表,突破请求体 500 台上限
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/btpanel_schemas.py` — `all_unconfigured`
|
||||
- `server/application/services/btpanel_service.py`
|
||||
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue`
|
||||
- `frontend/src/pages/btpanel/BtPanelSettingsPage.vue`
|
||||
- `frontend/src/api/btpanel.ts`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_*.py -q
|
||||
cd frontend && npx vite build
|
||||
# 服务器列表 → 全部未配置 / 勾选 → 查看批量任务进度
|
||||
```
|
||||
@@ -85,10 +85,16 @@ export function bootstrapBtServer(serverId: number) {
|
||||
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/bootstrap`, { method: 'POST', body: '{}' })
|
||||
}
|
||||
|
||||
export function batchBootstrapBtServers(serverIds: number[]) {
|
||||
export function batchBootstrapBtServers(
|
||||
serverIds: number[],
|
||||
options?: { allUnconfigured?: boolean },
|
||||
) {
|
||||
return api<BatchJobResponse>('/btpanel/servers/batch-bootstrap', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ server_ids: serverIds }),
|
||||
body: JSON.stringify({
|
||||
server_ids: serverIds,
|
||||
all_unconfigured: options?.allUnconfigured ?? false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,30 @@
|
||||
<div>
|
||||
<h1 class="text-h6 font-weight-bold">宝塔 · 服务器列表</h1>
|
||||
<p class="text-caption text-medium-emphasis mb-0">
|
||||
选择服务器一键打开宝塔面板(临时链接约 3 小时有效)
|
||||
先批量 SSH 获取 API,再一键登录;未配置 API 时登录链接易 404
|
||||
</p>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-download-network"
|
||||
:disabled="!unconfiguredCount"
|
||||
:loading="batchLoading"
|
||||
@click="openBatchConfirm('all')"
|
||||
>
|
||||
全部未配置({{ unconfiguredCount }})
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-playlist-check"
|
||||
:disabled="!selected.length"
|
||||
:loading="batchLoading"
|
||||
@click="openBatchConfirm('selected')"
|
||||
>
|
||||
选中获取 API({{ selected.length }})
|
||||
</v-btn>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact"
|
||||
@@ -20,11 +40,13 @@
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
v-model="selected"
|
||||
:headers="headers"
|
||||
:items="filteredServers"
|
||||
:loading="loading"
|
||||
density="compact"
|
||||
item-value="id"
|
||||
show-select
|
||||
hover
|
||||
>
|
||||
<template #item.configured="{ item }">
|
||||
@@ -54,8 +76,10 @@
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
:disabled="!item.configured"
|
||||
:loading="loginLoadingId === item.id"
|
||||
@click="openLogin(item.id)"
|
||||
:title="item.configured ? '打开宝塔临时登录' : '请先 SSH 获取 API'"
|
||||
@click="openLogin(item)"
|
||||
>
|
||||
一键登录
|
||||
</v-btn>
|
||||
@@ -69,19 +93,49 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<v-dialog v-model="batchConfirmOpen" max-width="520">
|
||||
<v-card title="批量 SSH 获取宝塔 API">
|
||||
<v-card-text>
|
||||
<p class="mb-2">
|
||||
将对 <strong>{{ batchTargetCount }}</strong> 台服务器执行 SSH 引导:开启子机
|
||||
<code>api.json</code>、写入 Nexus 加密凭据、追加中心 IP 白名单。
|
||||
</p>
|
||||
<p class="text-caption text-medium-emphasis mb-0">
|
||||
前提:子机 root SSH 可达、已装宝塔(未装会标记为安装中并每 5 分钟重试)。
|
||||
连接配置中需已设置「中心调用 IP」。
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="batchConfirmOpen = false">取消</v-btn>
|
||||
<v-btn color="primary" :loading="batchLoading" @click="confirmBatchBootstrap">开始</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { createBtLoginUrl, listBtServers, type BtPanelServerRow } from '@/api/btpanel'
|
||||
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
|
||||
import {
|
||||
batchBootstrapBtServers,
|
||||
createBtLoginUrl,
|
||||
listBtServers,
|
||||
type BtPanelServerRow,
|
||||
} from '@/api/btpanel'
|
||||
|
||||
defineOptions({ name: 'BtPanelLoginPage' })
|
||||
|
||||
const loading = ref(false)
|
||||
const batchLoading = ref(false)
|
||||
const batchConfirmOpen = ref(false)
|
||||
const batchMode = ref<'all' | 'selected'>('all')
|
||||
const loginLoadingId = ref<number | null>(null)
|
||||
const search = ref('')
|
||||
const servers = ref<BtPanelServerRow[]>([])
|
||||
const selected = ref<BtPanelServerRow[]>([])
|
||||
|
||||
const headers = [
|
||||
{ title: '名称', key: 'name', minWidth: 120 },
|
||||
@@ -101,6 +155,12 @@ const bootstrapStateLabels: Record<string, string> = {
|
||||
disabled: '已关闭',
|
||||
}
|
||||
|
||||
const unconfiguredCount = computed(() => servers.value.filter(s => !s.configured).length)
|
||||
|
||||
const batchTargetCount = computed(() =>
|
||||
batchMode.value === 'all' ? unconfiguredCount.value : selected.value.filter(s => !s.configured).length,
|
||||
)
|
||||
|
||||
function bootstrapLabel(state: string) {
|
||||
return bootstrapStateLabels[state] ?? state
|
||||
}
|
||||
@@ -123,10 +183,26 @@ const filteredServers = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function openBatchConfirm(mode: 'all' | 'selected') {
|
||||
batchMode.value = mode
|
||||
if (mode === 'selected') {
|
||||
const targets = selected.value.filter(s => !s.configured)
|
||||
if (!targets.length) {
|
||||
window.$snackbar?.('所选服务器均已配置 API', 'info')
|
||||
return
|
||||
}
|
||||
} else if (!unconfiguredCount.value) {
|
||||
window.$snackbar?.('没有未配置的服务器', 'info')
|
||||
return
|
||||
}
|
||||
batchConfirmOpen.value = true
|
||||
}
|
||||
|
||||
async function loadServers() {
|
||||
loading.value = true
|
||||
try {
|
||||
servers.value = await listBtServers()
|
||||
selected.value = selected.value.filter(s => servers.value.some(row => row.id === s.id))
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载服务器列表失败', 'error')
|
||||
} finally {
|
||||
@@ -134,10 +210,14 @@ async function loadServers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogin(serverId: number) {
|
||||
loginLoadingId.value = serverId
|
||||
async function openLogin(item: BtPanelServerRow) {
|
||||
if (!item.configured) {
|
||||
window.$snackbar?.('请先批量或单机「SSH 获取 API」', 'warning')
|
||||
return
|
||||
}
|
||||
loginLoadingId.value = item.id
|
||||
try {
|
||||
const { url } = await createBtLoginUrl(serverId)
|
||||
const { url } = await createBtLoginUrl(item.id)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.$snackbar?.('已打开宝塔登录链接', 'success')
|
||||
} catch (e: unknown) {
|
||||
@@ -147,5 +227,26 @@ async function openLogin(serverId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchBootstrap() {
|
||||
batchLoading.value = true
|
||||
try {
|
||||
let job
|
||||
if (batchMode.value === 'all') {
|
||||
job = await batchBootstrapBtServers([], { allUnconfigured: true })
|
||||
} else {
|
||||
const ids = selected.value.filter(s => !s.configured).map(s => s.id)
|
||||
job = await batchBootstrapBtServers(ids)
|
||||
}
|
||||
batchConfirmOpen.value = false
|
||||
window.$snackbar?.(`已启动批量任务 #${job.job_id}`, 'success')
|
||||
await openServerBatchJobResult(job.job_id, job.label)
|
||||
void loadServers()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '批量任务启动失败', 'error')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadServers() })
|
||||
</script>
|
||||
|
||||
@@ -311,11 +311,10 @@ async function runBootstrap(id: number) {
|
||||
}
|
||||
|
||||
async function confirmBatchBootstrap() {
|
||||
const ids = unconfiguredIds.value
|
||||
if (!ids.length) return
|
||||
if (!unconfiguredIds.value.length) return
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const job = await batchBootstrapBtServers(ids)
|
||||
const job = await batchBootstrapBtServers([], { allUnconfigured: true })
|
||||
batchConfirmOpen.value = false
|
||||
window.$snackbar?.(`已启动批量任务 #${job.job_id}`, 'success')
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -151,6 +151,7 @@ async def batch_bootstrap_bt_servers(
|
||||
try:
|
||||
return await _svc(db).batch_bootstrap(
|
||||
body.server_ids,
|
||||
all_unconfigured=body.all_unconfigured,
|
||||
admin_username=admin.username,
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class BtPanelConfigUpdate(BaseModel):
|
||||
@@ -18,7 +18,14 @@ class BtPanelGlobalSettingsUpdate(BaseModel):
|
||||
|
||||
|
||||
class BtPanelBatchBootstrap(BaseModel):
|
||||
server_ids: list[int] = Field(..., min_length=1, max_length=500)
|
||||
server_ids: list[int] = Field(default_factory=list, max_length=500)
|
||||
all_unconfigured: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_targets(self) -> "BtPanelBatchBootstrap":
|
||||
if not self.all_unconfigured and not self.server_ids:
|
||||
raise ValueError("server_ids 不能为空,或设置 all_unconfigured=true")
|
||||
return self
|
||||
|
||||
|
||||
class BtPanelSiteAction(BaseModel):
|
||||
|
||||
@@ -330,14 +330,19 @@ class BtPanelService:
|
||||
self,
|
||||
server_ids: list[int],
|
||||
*,
|
||||
all_unconfigured: bool = False,
|
||||
admin_username: str,
|
||||
ip_address: str | None,
|
||||
) -> dict[str, Any]:
|
||||
from server.application.services.server_batch_service import start_batch_job
|
||||
|
||||
ids = [int(x) for x in server_ids if x]
|
||||
if all_unconfigured:
|
||||
statuses = await self.list_server_statuses()
|
||||
ids = [int(s["id"]) for s in statuses if not s.get("configured")]
|
||||
else:
|
||||
ids = [int(x) for x in server_ids if x]
|
||||
if not ids:
|
||||
raise ValueError("server_ids 不能为空")
|
||||
raise ValueError("没有可批量初始化的服务器(均已配置或未录入)")
|
||||
return await start_batch_job(
|
||||
op="bt-panel-bootstrap",
|
||||
server_ids=ids,
|
||||
|
||||
Reference in New Issue
Block a user