Files
Nexus/frontend/src/pages/CredentialsPage.vue
T
Nexus Deploy 4ba45abf38 feat(servers): 凭据轮询登录与连接失败列表
- 密码/SSH密钥预设增加用户名;快速添加(IP)轮询全部预设尝试 SSH 登录
- 成功入 servers 列表;失败写入 pending_servers 并支持重试/删除
- 新增 credential_poller、try_ssh_login 与 add-by-ip/pending API
2026-06-06 20:08:48 +08:00

413 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<v-container fluid class="pa-6">
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
凭据管理
<v-spacer />
<v-btn-toggle v-model="credTab" mandatory density="compact" variant="outlined" class="mr-3">
<v-btn size="small" value="passwords">密码预设</v-btn>
<v-btn size="small" value="sshkeys">SSH 密钥</v-btn>
<v-btn size="small" value="db">数据库凭据</v-btn>
</v-btn-toggle>
<v-btn color="primary" variant="flat" size="small" prepend-icon="mdi-plus" @click="openAdd">添加</v-btn>
</v-card-title>
<v-divider />
<template v-if="credTab === 'passwords'">
<v-card-text class="text-body-2 text-medium-emphasis pt-0 pb-2">
保存常用 SSH 账号+密码模板快速添加服务器时将按顺序轮询所有预设尝试登录
</v-card-text>
<v-data-table
:items="passwords"
:headers="passwordHeaders"
:loading="loading"
hover
density="comfortable"
:items-per-page="20"
>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatPresetTime(item.created_at) }}</span>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editPassword(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('password', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无密码预设</div>
</template>
</v-data-table>
</template>
<!-- SSH Keys Tab -->
<template v-if="credTab === 'sshkeys'">
<v-data-table-server
:items="sshKeys"
:headers="sshKeyHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadSshKeys() }"
@update:items-per-page="(n: number) => { itemsPerPage = n; page = 1; loadSshKeys() }"
>
<template #item.private_key_set="{ item }">
<v-chip :color="item.private_key_set ? 'success' : 'grey'" size="small" variant="tonal" label>
{{ item.private_key_set ? '已设置' : '未设置' }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editSshKey(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('sshkey', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
</template>
</v-data-table-server>
</template>
<!-- DB Credentials Tab -->
<template v-if="credTab === 'db'">
<v-data-table-server
:items="dbCreds"
:headers="dbCredHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadDbCreds() }"
@update:items-per-page="(n: number) => { itemsPerPage = n; page = 1; loadDbCreds() }"
>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editDbCred(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('db', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
</template>
</v-data-table-server>
</template>
</v-card>
<!-- Password Dialog -->
<v-dialog v-model="showPasswordDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑密码预设' : '新建密码预设' }}</v-card-title>
<v-card-text>
<v-text-field
v-model="pwForm.name"
label="预设名称"
variant="outlined"
density="compact"
class="mb-2"
placeholder="生产环境 root 密码"
:rules="[required('预设名称')]"
/>
<v-text-field
v-model="pwForm.username"
label="SSH 用户名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="root"
:rules="[required('SSH 用户名')]"
/>
<v-text-field
v-model="pwForm.password"
label="SSH 密码"
variant="outlined"
density="compact"
type="password"
:placeholder="editingId ? '留空表示不修改' : '必填'"
:rules="editingId ? [] : [required('SSH 密码')]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showPasswordDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="savePassword">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- SSH Key Dialog -->
<v-dialog v-model="showSshKeyDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑 SSH 密钥' : '新建 SSH 密钥' }}</v-card-title>
<v-card-text>
<v-text-field v-model="skForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="skForm.username" label="SSH 用户名" variant="outlined" density="compact" class="mb-2" placeholder="root" />
<v-textarea v-model="skForm.public_key" label="公钥" variant="outlined" density="compact" rows="3" class="mb-2" />
<v-textarea v-model="skForm.private_key" label="私钥" variant="outlined" density="compact" rows="5" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showSshKeyDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveSshKey">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- DB Credential Dialog -->
<v-dialog v-model="showDbCredDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑数据库凭据' : '新建数据库凭据' }}</v-card-title>
<v-card-text>
<v-text-field v-model="dbForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.host" label="主机" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.port" label="端口" variant="outlined" density="compact" type="number" class="mb-2" />
<v-text-field v-model="dbForm.username" label="用户名" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.password" label="密码" variant="outlined" density="compact" type="password" class="mb-2" />
<v-text-field v-model="dbForm.database" label="数据库" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDbCredDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveDbCred">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirm -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>确定要删除此凭据吗?</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDelete">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
const snackbar = useSnackbar()
function formatPresetTime(iso: string | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false })
}
// ── Credential types ──
interface PasswordItem { id: number; name: string; username?: string; created_at?: string }
interface SshKeyItem { id: number; name: string; username?: string; public_key?: string; private_key_set?: boolean; description?: string }
interface DbCredItem { id: number; name: string; db_type?: string; host: string; port: number; username: string; password_set?: boolean; database?: string }
// ── State ──
const loading = ref(false)
const credTab = ref<'passwords' | 'sshkeys' | 'db'>('passwords')
const editingId = ref<number | null>(null)
// Pagination
const page = ref(1)
const totalItems = ref(0)
const itemsPerPage = ref(20)
// Password presets
const passwords = ref<PasswordItem[]>([])
const showPasswordDialog = ref(false)
const pwForm = ref({ name: '', username: 'root', password: '' })
const passwordHeaders = [
{ 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 = [
{ 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 = [
{ 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)
const deleteType = ref('')
const deleteId = ref<number | null>(null)
// ── Data loading ──
async function loadPasswords() {
loading.value = true
try {
const res = await http.getList<PasswordItem>('/presets/')
passwords.value = res.items
totalItems.value = res.total
} catch (e: unknown) {
passwords.value = []
snackbar(formatApiError(e, '加载密码预设失败'), 'error')
}
finally { loading.value = false }
}
async function loadSshKeys() {
loading.value = true
try {
const res = await http.getList<SshKeyItem>('/ssh-key-presets/', {
page: page.value,
per_page: itemsPerPage.value,
})
sshKeys.value = res.items
totalItems.value = res.total
} catch (e: unknown) {
sshKeys.value = []
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
}
finally { loading.value = false }
}
async function loadDbCreds() {
loading.value = true
try {
const res = await http.getList<DbCredItem>('/scripts/credentials', {
page: page.value,
per_page: itemsPerPage.value,
})
dbCreds.value = res.items
totalItems.value = res.total
} catch (e: unknown) {
dbCreds.value = []
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
}
finally { loading.value = false }
}
function loadCurrentTab() {
switch (credTab.value) {
case 'passwords': loadPasswords(); break
case 'sshkeys': loadSshKeys(); break
case 'db': loadDbCreds(); break
}
}
watch(credTab, () => { page.value = 1; loadCurrentTab() })
// ── Password CRUD ──
function editPassword(item: PasswordItem) {
editingId.value = item.id
pwForm.value = { name: item.name, username: item.username || 'root', password: '' }
showPasswordDialog.value = true
}
async function savePassword() {
const name = pwForm.value.name.trim()
const password = pwForm.value.password
if (!name) {
snackbar('请填写预设名称', 'error')
return
}
if (!editingId.value && !password) {
snackbar('请填写 SSH 密码', 'error')
return
}
try {
const username = pwForm.value.username.trim() || 'root'
if (editingId.value) {
const body: { name: string; username: string; encrypted_pw?: string } = { name, username }
if (password) body.encrypted_pw = password
await http.put(`/presets/${editingId.value}`, body)
} else {
await http.post('/presets/', { name, username, encrypted_pw: password })
}
showPasswordDialog.value = false
loadPasswords()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
}
}
// ── SSH Key CRUD ──
function editSshKey(item: SshKeyItem) {
editingId.value = item.id
skForm.value = { name: item.name, username: item.username || 'root', public_key: item.public_key || '', private_key: '' }
showSshKeyDialog.value = true
}
async function saveSshKey() {
try {
if (editingId.value) await http.put(`/ssh-key-presets/${editingId.value}`, skForm.value)
else await http.post('/ssh-key-presets/', skForm.value)
showSshKeyDialog.value = false
loadSshKeys()
snackbar('保存成功')
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
}
// ── DB Credential CRUD ──
function editDbCred(item: DbCredItem) {
editingId.value = item.id
dbForm.value = { name: item.name, host: item.host || '', port: item.port || 3306, username: item.username || '', password: '', database: item.database || '' }
showDbCredDialog.value = true
}
async function saveDbCred() {
try {
if (editingId.value) await http.put(`/scripts/credentials/${editingId.value}`, dbForm.value)
else await http.post('/scripts/credentials', dbForm.value)
showDbCredDialog.value = false
loadDbCreds()
snackbar('保存成功')
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
}
// ── Delete ──
function openAdd() {
editingId.value = null
if (credTab.value === 'passwords') { pwForm.value = { name: '', username: 'root', password: '' }; showPasswordDialog.value = true }
else if (credTab.value === 'sshkeys') { skForm.value = { name: '', username: 'root', public_key: '', private_key: '' }; showSshKeyDialog.value = true }
else { dbForm.value = { name: '', host: '', port: 3306, username: '', password: '', database: '' }; showDbCredDialog.value = true }
}
function confirmDelete(type: string, item: { id: number }) {
deleteType.value = type
deleteId.value = item.id
showDeleteDialog.value = true
}
async function doDelete() {
try {
if (deleteType.value === 'password') await http.delete(`/presets/${deleteId.value}`)
else if (deleteType.value === 'sshkey') await http.delete(`/ssh-key-presets/${deleteId.value}`)
else await http.delete(`/scripts/credentials/${deleteId.value}`)
showDeleteDialog.value = false
loadCurrentTab()
snackbar('已删除')
} catch (e: any) { snackbar(e.message || '删除失败', 'error') }
}
onMounted(() => {
loadCurrentTab()
})
</script>