feat(servers): 凭据轮询登录与连接失败列表
- 密码/SSH密钥预设增加用户名;快速添加(IP)轮询全部预设尝试 SSH 登录 - 成功入 servers 列表;失败写入 pending_servers 并支持重试/删除 - 新增 credential_poller、try_ssh_login 与 add-by-ip/pending API
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# 2026-06-06 — 凭据轮询登录与连接失败列表
|
||||
|
||||
## 摘要
|
||||
|
||||
密码/SSH 密钥预设增加用户名;快速添加服务器仅需 IP,自动轮询全部凭据尝试 SSH 登录;失败进入 `pending_servers` 列表并可重试。
|
||||
|
||||
## 动机
|
||||
|
||||
批量添加 2000+ 子机时,每台需尝试多组账号密码;手动填用户名密码效率低,需凭据预设驱动自动探测。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/domain/models/__init__.py` — `username` 字段、`PendingServer` 模型
|
||||
- `server/infrastructure/database/migrations.py` — 幂等 schema 迁移
|
||||
- `server/infrastructure/ssh/asyncssh_pool.py` — `try_ssh_login`
|
||||
- `server/application/services/credential_poller.py` — 轮询服务(新)
|
||||
- `server/infrastructure/database/pending_server_repo.py` — 失败队列 repo(新)
|
||||
- `server/api/servers.py` — `add-by-ip`、`/pending`、retry、delete
|
||||
- `server/api/schemas.py`、`server/api/settings.py` — 预设 username
|
||||
- `frontend/src/pages/CredentialsPage.vue` — 凭据 UI
|
||||
- `frontend/src/pages/ServersPage.vue` — 快速添加 + 失败列表
|
||||
- `tests/test_credential_poller.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 启动时 `run_schema_migrations()` 自动 ALTER/CREATE
|
||||
- 需重启 API;前端需 `vite build` 部署
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_credential_poller.py -q
|
||||
cd frontend && npx vite build
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/servers/add-by-ip` | 仅 IP,轮询凭据 |
|
||||
| GET | `/api/servers/pending` | 失败列表 |
|
||||
| POST | `/api/servers/pending/{id}/retry` | 重试轮询 |
|
||||
| DELETE | `/api/servers/pending/{id}` | 删除 pending |
|
||||
@@ -0,0 +1,32 @@
|
||||
# 凭据轮询登录与连接失败列表
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 状态 | 已实施 |
|
||||
|
||||
## 背景
|
||||
|
||||
运维批量添加子机时,每台机器需尝试多组账号密码/密钥。原密码预设仅含密码、用户名在服务器表单单独填写,添加时不做 SSH 探测。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 密码/SSH 密钥预设增加 `username` 字段。
|
||||
2. `POST /api/servers/add-by-ip`:仅 IP,顺序轮询全部预设,`try_ssh_login` 验证。
|
||||
3. 成功:创建 `servers` 行,`connectivity=ok`,记录命中预设。
|
||||
4. 失败:写入 `pending_servers`,前端弹窗 + 失败列表展示,支持重试/删除。
|
||||
|
||||
## 数据模型
|
||||
|
||||
- `password_presets.username`、`ssh_key_presets.username`(默认 root)
|
||||
- `pending_servers`:domain、port、attempts、last_error、last_attempt_at 等
|
||||
|
||||
## 安全
|
||||
|
||||
- 轮询明文仅在内存;落库 Fernet 加密;失败摘要不含完整密码/私钥。
|
||||
|
||||
## 验收
|
||||
|
||||
- 凭据页可维护用户名
|
||||
- 快速添加(IP) 轮询成功入 servers 列表
|
||||
- 全失败入 pending 列表,重试重新轮询全部预设
|
||||
@@ -65,7 +65,7 @@
|
||||
<v-select
|
||||
v-model="form.preset_id"
|
||||
:items="passwordPresets"
|
||||
item-title="name"
|
||||
item-title="display"
|
||||
item-value="id"
|
||||
label="密码预设(可选)"
|
||||
variant="outlined"
|
||||
@@ -91,7 +91,7 @@
|
||||
<v-select
|
||||
v-model="form.ssh_key_preset_id"
|
||||
:items="sshKeyPresets"
|
||||
item-title="name"
|
||||
item-title="display"
|
||||
item-value="id"
|
||||
label="SSH 密钥预设(可选)"
|
||||
variant="outlined"
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface ServerFormModel {
|
||||
export interface PresetOption {
|
||||
id: number
|
||||
name: string
|
||||
username?: string
|
||||
display?: string
|
||||
}
|
||||
|
||||
function emptyForm(): ServerFormModel {
|
||||
@@ -117,8 +119,14 @@ export function useServerFormDialog(onSaved: () => void) {
|
||||
}
|
||||
categoryOptions.value = [...names].sort((a, b) => a.localeCompare(b, 'zh'))
|
||||
platforms.value = (catRes.platforms || []).map((p) => ({ id: p.id, name: p.name }))
|
||||
passwordPresets.value = pwRes.items || []
|
||||
sshKeyPresets.value = skRes.items || []
|
||||
passwordPresets.value = (pwRes.items || []).map((p) => ({
|
||||
...p,
|
||||
display: `${p.name} (${p.username || 'root'})`,
|
||||
}))
|
||||
sshKeyPresets.value = (skRes.items || []).map((p) => ({
|
||||
...p,
|
||||
display: `${p.name} (${p.username || 'root'})`,
|
||||
}))
|
||||
nodes.value = Array.isArray(nodesRes) ? nodesRes : []
|
||||
} catch {
|
||||
snackbar('加载表单选项失败', 'error')
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- Password Presets Tab:仅名称+密码模板,不含 SSH 用户名(见 docs/design/plans/2026-05-16-password-presets.md) -->
|
||||
<template v-if="credTab === 'passwords'">
|
||||
<v-card-text class="text-body-2 text-medium-emphasis pt-0 pb-2">
|
||||
保存常用 SSH 密码模板;在「服务器」里选择预设后由服务端解密填入。SSH 用户名在每台服务器上单独配置。
|
||||
保存常用 SSH 账号+密码模板;快速添加服务器时将按顺序轮询所有预设尝试登录。
|
||||
</v-card-text>
|
||||
<v-data-table
|
||||
:items="passwords"
|
||||
@@ -108,6 +107,15 @@
|
||||
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 密码"
|
||||
@@ -132,6 +140,7 @@
|
||||
<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>
|
||||
@@ -194,7 +203,7 @@ function formatPresetTime(iso: string | undefined): string {
|
||||
}
|
||||
|
||||
// ── Credential types ──
|
||||
interface PasswordItem { id: number; name: string; created_at?: string }
|
||||
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 }
|
||||
|
||||
@@ -211,9 +220,10 @@ const itemsPerPage = ref(20)
|
||||
// Password presets
|
||||
const passwords = ref<PasswordItem[]>([])
|
||||
const showPasswordDialog = ref(false)
|
||||
const pwForm = ref({ name: '', password: '' })
|
||||
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 },
|
||||
]
|
||||
@@ -221,9 +231,10 @@ const passwordHeaders = [
|
||||
// SSH keys
|
||||
const sshKeys = ref<SshKeyItem[]>([])
|
||||
const showSshKeyDialog = ref(false)
|
||||
const skForm = ref({ name: '', public_key: '', private_key: '' })
|
||||
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 },
|
||||
]
|
||||
@@ -303,7 +314,7 @@ watch(credTab, () => { page.value = 1; loadCurrentTab() })
|
||||
// ── Password CRUD ──
|
||||
function editPassword(item: PasswordItem) {
|
||||
editingId.value = item.id
|
||||
pwForm.value = { name: item.name, password: '' }
|
||||
pwForm.value = { name: item.name, username: item.username || 'root', password: '' }
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
|
||||
@@ -319,12 +330,13 @@ async function savePassword() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const username = pwForm.value.username.trim() || 'root'
|
||||
if (editingId.value) {
|
||||
const body: { name: string; encrypted_pw?: string } = { name }
|
||||
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, encrypted_pw: password })
|
||||
await http.post('/presets/', { name, username, encrypted_pw: password })
|
||||
}
|
||||
showPasswordDialog.value = false
|
||||
loadPasswords()
|
||||
@@ -337,7 +349,7 @@ async function savePassword() {
|
||||
// ── SSH Key CRUD ──
|
||||
function editSshKey(item: SshKeyItem) {
|
||||
editingId.value = item.id
|
||||
skForm.value = { name: item.name, public_key: item.public_key || '', private_key: '' }
|
||||
skForm.value = { name: item.name, username: item.username || 'root', public_key: item.public_key || '', private_key: '' }
|
||||
showSshKeyDialog.value = true
|
||||
}
|
||||
|
||||
@@ -371,8 +383,8 @@ async function saveDbCred() {
|
||||
// ── Delete ──
|
||||
function openAdd() {
|
||||
editingId.value = null
|
||||
if (credTab.value === 'passwords') { pwForm.value = { name: '', password: '' }; showPasswordDialog.value = true }
|
||||
else if (credTab.value === 'sshkeys') { skForm.value = { name: '', public_key: '', private_key: '' }; showSshKeyDialog.value = true }
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<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-download" size="small" @click="exportCSV">
|
||||
导出CSV
|
||||
</v-btn>
|
||||
@@ -103,6 +106,49 @@
|
||||
</v-data-table-server>
|
||||
</v-card>
|
||||
|
||||
<!-- Pending / failed SSH connections -->
|
||||
<v-card elevation="0" rounded="lg" class="my-5" border>
|
||||
<v-card-title class="d-flex align-center">
|
||||
连接失败列表
|
||||
<v-chip v-if="pendingServers.length" size="small" class="ml-2" color="error" variant="tonal" label>
|
||||
{{ pendingServers.length }}
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" size="small" prepend-icon="mdi-refresh" :loading="pendingLoading" @click="loadPendingServers">
|
||||
刷新
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:items="pendingServers"
|
||||
:headers="pendingHeaders"
|
||||
:loading="pendingLoading"
|
||||
hover
|
||||
density="comfortable"
|
||||
:items-per-page="10"
|
||||
>
|
||||
<template #item.address="{ item }">
|
||||
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
|
||||
</template>
|
||||
<template #item.last_attempt_at="{ item }">
|
||||
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_attempt_at) }}</span>
|
||||
</template>
|
||||
<template #item.last_error="{ item }">
|
||||
<span class="text-caption text-error text-truncate d-inline-block" style="max-width: 320px" :title="item.last_error">
|
||||
{{ item.last_error || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn variant="text" size="x-small" color="primary" density="compact" :loading="retryingId === item.id" @click="retryPending(item)">
|
||||
重试
|
||||
</v-btn>
|
||||
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDeletePending(item)">删除</v-btn>
|
||||
</template>
|
||||
<template #no-data>
|
||||
<div class="text-center text-medium-emphasis py-6">暂无连接失败记录</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Server Detail Panel -->
|
||||
<v-expand-transition>
|
||||
<v-card v-if="detailServer" class="mt-4" elevation="0" border rounded="lg">
|
||||
@@ -188,6 +234,84 @@
|
||||
</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_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>
|
||||
|
||||
<!-- Delete pending confirm -->
|
||||
<v-dialog v-model="showDeletePending" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>确认删除</v-card-title>
|
||||
<v-card-text>从失败列表移除 <strong>{{ deletingPending?.domain }}</strong>?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showDeletePending = false">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" :loading="deletingPendingLoading" @click="doDeletePending">删除</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Import CSV Dialog -->
|
||||
<v-dialog v-model="showImport" max-width="500">
|
||||
<v-card border>
|
||||
@@ -247,6 +371,153 @@ const showDelete = ref(false)
|
||||
const deleting = ref(false)
|
||||
const deletingServer = ref<ServerApiItem | null>(null)
|
||||
|
||||
// ── Quick add by IP + pending failure list ──
|
||||
interface PollErrorItem {
|
||||
preset_type: string
|
||||
preset_name: string
|
||||
username: string
|
||||
error: string
|
||||
}
|
||||
|
||||
interface PendingServerItem {
|
||||
id: number
|
||||
name: string
|
||||
domain: string
|
||||
port: number
|
||||
attempts: number
|
||||
last_error: string
|
||||
last_attempt_at: string | null
|
||||
}
|
||||
|
||||
interface AddByIpResponse {
|
||||
success: boolean
|
||||
server?: ServerApiItem
|
||||
pending_id?: number
|
||||
errors?: PollErrorItem[]
|
||||
message?: string
|
||||
matched_preset?: string
|
||||
matched_username?: string
|
||||
}
|
||||
|
||||
const showQuickAdd = ref(false)
|
||||
const quickAddLoading = ref(false)
|
||||
const quickAddForm = ref({ domain: '', port: 22, name: '' })
|
||||
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)
|
||||
|
||||
const pendingHeaders = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '地址', key: 'address' },
|
||||
{ title: '尝试次数', key: 'attempts', width: 100 },
|
||||
{ title: '最后错误', key: 'last_error' },
|
||||
{ title: '最后尝试', key: 'last_attempt_at', width: 160 },
|
||||
{ title: '操作', key: 'actions', width: 140, align: 'end' as const },
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function loadPendingServers() {
|
||||
pendingLoading.value = true
|
||||
try {
|
||||
const res = await http.get<{ items: PendingServerItem[]; total: number }>('/servers/pending')
|
||||
pendingServers.value = res.items || []
|
||||
} catch (e: unknown) {
|
||||
pendingServers.value = []
|
||||
snackbar(formatApiError(e, '加载失败列表失败'), 'error')
|
||||
} finally {
|
||||
pendingLoading.value = 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
|
||||
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 {
|
||||
const res = await http.post<AddByIpResponse>(`/servers/pending/${item.id}/retry`, {})
|
||||
if (res.success && res.server) {
|
||||
snackbar(`重试成功:${res.matched_preset} (${res.matched_username})`)
|
||||
loadServers()
|
||||
loadStats()
|
||||
loadPendingServers()
|
||||
} else {
|
||||
showFailureDialog(res.errors || [], res.message)
|
||||
loadPendingServers()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '重试失败'), 'error')
|
||||
} finally {
|
||||
retryingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeletePending(item: PendingServerItem) {
|
||||
deletingPending.value = item
|
||||
showDeletePending.value = true
|
||||
}
|
||||
|
||||
async function doDeletePending() {
|
||||
if (!deletingPending.value) return
|
||||
deletingPendingLoading.value = true
|
||||
try {
|
||||
await http.delete(`/servers/pending/${deletingPending.value.id}`)
|
||||
showDeletePending.value = false
|
||||
snackbar('已从失败列表移除')
|
||||
loadPendingServers()
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '删除失败'), 'error')
|
||||
} finally {
|
||||
deletingPendingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
visible: serverFormVisible,
|
||||
form: serverForm,
|
||||
@@ -473,5 +744,6 @@ async function doDelete() {
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadServers()
|
||||
loadPendingServers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -589,11 +589,13 @@ class ScheduleUpdate(BaseModel):
|
||||
|
||||
class PresetCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
username: str = Field("root", min_length=1, max_length=100)
|
||||
encrypted_pw: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class PresetUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
username: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
encrypted_pw: Optional[str] = Field(None, min_length=1, description="留空则不修改密码")
|
||||
|
||||
|
||||
@@ -601,16 +603,34 @@ class PresetUpdate(BaseModel):
|
||||
|
||||
class SshKeyPresetCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
username: str = Field("root", min_length=1, max_length=100)
|
||||
private_key: str = Field(..., min_length=1)
|
||||
public_key: Optional[str] = None
|
||||
|
||||
|
||||
class SshKeyPresetUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
username: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
private_key: Optional[str] = Field(None, min_length=1, description="留空则不修改私钥")
|
||||
public_key: Optional[str] = None
|
||||
|
||||
|
||||
class AddByIpRequest(BaseModel):
|
||||
domain: str = Field(..., min_length=1, max_length=255, description="IP 或域名")
|
||||
port: int = Field(22, ge=1, le=65535)
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
agent_port: int = Field(8601, ge=1, le=65535)
|
||||
target_path: Optional[str] = Field("", description="推送目标路径,可后续编辑")
|
||||
category: Optional[str] = None
|
||||
platform_id: Optional[int] = None
|
||||
node_id: Optional[int] = None
|
||||
|
||||
@field_validator("target_path", mode="before")
|
||||
@classmethod
|
||||
def _normalize_target_path_add(cls, v: object) -> object:
|
||||
return coerce_optional_remote_abs_path(v if v is None else str(v))
|
||||
|
||||
|
||||
# ── Platform / Node ──
|
||||
|
||||
class PlatformCreate(BaseModel):
|
||||
|
||||
+300
-1
@@ -18,10 +18,11 @@ from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import (
|
||||
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
|
||||
ServerImportResult, BatchAgentAction, BatchAgentResult, BatchAgentResultItem,
|
||||
AddByIpRequest,
|
||||
)
|
||||
from server.application.services.server_service import ServerService
|
||||
from server.application.services.sync_service import SyncService
|
||||
from server.domain.models import Server, Admin, AuditLog
|
||||
from server.domain.models import Server, Admin, AuditLog, PendingServer
|
||||
from server.config import settings
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
@@ -910,6 +911,296 @@ async def batch_uninstall_agent(
|
||||
return BatchAgentResult(results=results)
|
||||
|
||||
|
||||
# ── Credential polling: add-by-ip + pending failure queue ──
|
||||
|
||||
|
||||
def _pending_to_dict(row: PendingServer) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"domain": row.domain,
|
||||
"port": row.port,
|
||||
"agent_port": row.agent_port,
|
||||
"target_path": row.target_path or "",
|
||||
"category": row.category,
|
||||
"platform_id": row.platform_id,
|
||||
"node_id": row.node_id,
|
||||
"attempts": row.attempts or 0,
|
||||
"last_error": row.last_error or "",
|
||||
"last_attempt_at": str(row.last_attempt_at) if row.last_attempt_at else None,
|
||||
"created_at": str(row.created_at) if row.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _unique_server_name(db: AsyncSession, base: str) -> str:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
candidate = base.strip()[:100] or "server"
|
||||
result = await db.execute(sa_select(Server.name).where(Server.name == candidate))
|
||||
if not result.scalar_one_or_none():
|
||||
return candidate
|
||||
for i in range(2, 1000):
|
||||
name = f"{candidate[:90]}-{i}"
|
||||
result = await db.execute(sa_select(Server.name).where(Server.name == name))
|
||||
if not result.scalar_one_or_none():
|
||||
return name
|
||||
raise HTTPException(500, "无法生成唯一服务器名称")
|
||||
|
||||
|
||||
async def _server_exists_for_domain(db: AsyncSession, domain: str) -> bool:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await db.execute(sa_select(Server.id).where(Server.domain == domain).limit(1))
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _create_server_from_poll(
|
||||
db: AsyncSession,
|
||||
service: ServerService,
|
||||
*,
|
||||
poll_match,
|
||||
name: str,
|
||||
domain: str,
|
||||
port: int,
|
||||
agent_port: int,
|
||||
target_path: str | None,
|
||||
category: str | None,
|
||||
platform_id: int | None,
|
||||
node_id: int | None,
|
||||
admin: Admin,
|
||||
request: Request,
|
||||
) -> Server:
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
|
||||
unique_name = await _unique_server_name(db, name)
|
||||
data: dict = {
|
||||
"name": unique_name,
|
||||
"domain": domain,
|
||||
"port": port,
|
||||
"username": poll_match.username,
|
||||
"auth_method": poll_match.auth_method,
|
||||
"agent_port": agent_port,
|
||||
"target_path": target_path or "",
|
||||
"category": category,
|
||||
"platform_id": platform_id,
|
||||
"node_id": node_id,
|
||||
"connectivity": "ok",
|
||||
"last_checked_at": datetime.now(timezone.utc),
|
||||
"agent_api_key": f"nxs-{secrets.token_urlsafe(32)}",
|
||||
}
|
||||
if poll_match.auth_method == "password":
|
||||
data["preset_id"] = poll_match.preset_id
|
||||
data["password"] = encrypt_value(poll_match.password)
|
||||
else:
|
||||
data["ssh_key_preset_id"] = poll_match.ssh_key_preset_id
|
||||
data["ssh_key_private"] = encrypt_value(poll_match.ssh_key_private)
|
||||
data["ssh_key_public"] = poll_match.ssh_key_public or ""
|
||||
data["ssh_key_configured"] = True
|
||||
|
||||
server = Server(**data)
|
||||
created = await service.create_server(server)
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="create_server",
|
||||
target_type="server",
|
||||
target_id=created.id,
|
||||
detail=(
|
||||
f"凭据轮询添加 名称={created.name} 地址={created.domain}:{created.port} "
|
||||
f"预设={poll_match.preset_name} 用户={poll_match.username}"
|
||||
),
|
||||
ip_address=ip_address,
|
||||
))
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/pending", response_model=dict)
|
||||
async def list_pending_servers(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List servers that failed SSH credential polling."""
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
repo = PendingServerRepositoryImpl(db)
|
||||
rows = await repo.get_all()
|
||||
items = [_pending_to_dict(r) for r in rows]
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.post("/add-by-ip", response_model=dict)
|
||||
async def add_server_by_ip(
|
||||
request: Request,
|
||||
payload: AddByIpRequest,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add server by IP only — poll all credential presets until SSH login succeeds."""
|
||||
from server.application.services.credential_poller import (
|
||||
format_poll_errors,
|
||||
poll_credentials,
|
||||
)
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
domain = payload.domain.strip()
|
||||
if await _server_exists_for_domain(db, domain):
|
||||
raise HTTPException(409, f"服务器 {domain} 已存在于列表中")
|
||||
|
||||
display_name = (payload.name or domain).strip()
|
||||
poll = await poll_credentials(db, domain, payload.port)
|
||||
|
||||
if poll.success and poll.match:
|
||||
created = await _create_server_from_poll(
|
||||
db,
|
||||
service,
|
||||
poll_match=poll.match,
|
||||
name=display_name,
|
||||
domain=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,
|
||||
admin=admin,
|
||||
request=request,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"server": _server_to_dict(created),
|
||||
"matched_preset": poll.match.preset_name,
|
||||
"matched_username": poll.match.username,
|
||||
}
|
||||
|
||||
err_text = format_poll_errors(poll.errors)
|
||||
pending_repo = PendingServerRepositoryImpl(db)
|
||||
pending = await pending_repo.record_failure(
|
||||
name=display_name,
|
||||
domain=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,
|
||||
last_error=err_text,
|
||||
)
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="add_server_by_ip_failed",
|
||||
target_type="pending_server",
|
||||
target_id=pending.id,
|
||||
detail=f"凭据轮询失败 地址={domain}:{payload.port}",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"pending_id": pending.id,
|
||||
"errors": [{"preset_type": e.preset_type, "preset_name": e.preset_name, "username": e.username, "error": e.error} for e in poll.errors],
|
||||
"message": err_text,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pending/{pending_id}/retry", response_model=dict)
|
||||
async def retry_pending_server(
|
||||
pending_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Re-poll all credential presets for a pending server."""
|
||||
from server.application.services.credential_poller import (
|
||||
format_poll_errors,
|
||||
poll_credentials,
|
||||
)
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
pending_repo = PendingServerRepositoryImpl(db)
|
||||
pending = await pending_repo.get_by_id(pending_id)
|
||||
if not pending:
|
||||
raise HTTPException(404, "待连接服务器不存在")
|
||||
|
||||
if await _server_exists_for_domain(db, pending.domain):
|
||||
await pending_repo.delete(pending_id)
|
||||
raise HTTPException(409, f"服务器 {pending.domain} 已存在于列表中,已从失败列表移除")
|
||||
|
||||
poll = await poll_credentials(db, pending.domain, pending.port or 22)
|
||||
|
||||
if poll.success and poll.match:
|
||||
created = await _create_server_from_poll(
|
||||
db,
|
||||
service,
|
||||
poll_match=poll.match,
|
||||
name=pending.name,
|
||||
domain=pending.domain,
|
||||
port=pending.port or 22,
|
||||
agent_port=pending.agent_port or 8601,
|
||||
target_path=pending.target_path,
|
||||
category=pending.category,
|
||||
platform_id=pending.platform_id,
|
||||
node_id=pending.node_id,
|
||||
admin=admin,
|
||||
request=request,
|
||||
)
|
||||
await pending_repo.delete(pending_id)
|
||||
return {
|
||||
"success": True,
|
||||
"server": _server_to_dict(created),
|
||||
"matched_preset": poll.match.preset_name,
|
||||
"matched_username": poll.match.username,
|
||||
}
|
||||
|
||||
err_text = format_poll_errors(poll.errors)
|
||||
pending.attempts = (pending.attempts or 0) + 1
|
||||
pending.last_error = err_text
|
||||
from datetime import datetime, timezone
|
||||
|
||||
pending.last_attempt_at = datetime.now(timezone.utc)
|
||||
await pending_repo.update(pending)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"pending_id": pending.id,
|
||||
"errors": [{"preset_type": e.preset_type, "preset_name": e.preset_name, "username": e.username, "error": e.error} for e in poll.errors],
|
||||
"message": err_text,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/pending/{pending_id}", status_code=204)
|
||||
async def delete_pending_server(
|
||||
pending_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
repo = PendingServerRepositoryImpl(db)
|
||||
if not await repo.delete(pending_id):
|
||||
raise HTTPException(404, "待连接服务器不存在")
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="delete_pending_server",
|
||||
target_type="pending_server",
|
||||
target_id=pending_id,
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=dict)
|
||||
async def get_server(
|
||||
id: int,
|
||||
@@ -1080,6 +1371,8 @@ async def create_server(
|
||||
preset = await preset_repo.get_by_id(data["preset_id"])
|
||||
if preset:
|
||||
data["password"] = decrypt_value(preset.encrypted_pw)
|
||||
if preset.username and (not data.get("username") or data.get("username") == "root"):
|
||||
data["username"] = preset.username
|
||||
# If preset not found, fall through — user may provide password manually
|
||||
|
||||
# If ssh_key_preset_id is provided, resolve the preset private key (server-side, no re-auth needed)
|
||||
@@ -1090,6 +1383,8 @@ async def create_server(
|
||||
data["ssh_key_private"] = decrypt_value(ssh_key_preset.encrypted_private_key)
|
||||
if ssh_key_preset.public_key and not data.get("ssh_key_public"):
|
||||
data["ssh_key_public"] = ssh_key_preset.public_key
|
||||
if ssh_key_preset.username and (not data.get("username") or data.get("username") == "root"):
|
||||
data["username"] = ssh_key_preset.username
|
||||
# If preset not found, fall through — user may provide key manually
|
||||
|
||||
# Encrypt sensitive fields before storing
|
||||
@@ -1148,6 +1443,8 @@ async def update_server(
|
||||
preset = await preset_repo.get_by_id(update_data["preset_id"])
|
||||
if preset:
|
||||
update_data["password"] = decrypt_value(preset.encrypted_pw)
|
||||
if preset.username:
|
||||
update_data["username"] = preset.username
|
||||
|
||||
# If ssh_key_preset_id is provided, resolve the preset private key
|
||||
if "ssh_key_preset_id" in update_data and update_data["ssh_key_preset_id"] is not None and not update_data.get("ssh_key_private"):
|
||||
@@ -1157,6 +1454,8 @@ async def update_server(
|
||||
update_data["ssh_key_private"] = decrypt_value(ssh_key_preset.encrypted_private_key)
|
||||
if ssh_key_preset.public_key:
|
||||
update_data["ssh_key_public"] = ssh_key_preset.public_key
|
||||
if ssh_key_preset.username:
|
||||
update_data["username"] = ssh_key_preset.username
|
||||
|
||||
# Explicit allowlist to prevent mass-assignment to sensitive columns
|
||||
# agent_api_key is excluded — must be set via dedicated /agent-key endpoint
|
||||
|
||||
+29
-4
@@ -491,7 +491,10 @@ async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSessi
|
||||
"""List all password presets"""
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
presets = await repo.get_all()
|
||||
return [{"id": p.id, "name": p.name, "created_at": str(p.created_at)} for p in presets]
|
||||
return [
|
||||
{"id": p.id, "name": p.name, "username": p.username or "root", "created_at": str(p.created_at)}
|
||||
for p in presets
|
||||
]
|
||||
|
||||
|
||||
@preset_router.post("/", response_model=dict, status_code=201)
|
||||
@@ -505,7 +508,11 @@ async def create_preset(
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
encrypted = encrypt_value(payload.encrypted_pw)
|
||||
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
|
||||
preset = PasswordPreset(
|
||||
name=payload.name,
|
||||
username=(payload.username or "root").strip() or "root",
|
||||
encrypted_pw=encrypted,
|
||||
)
|
||||
created = await repo.create(preset)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
@@ -541,6 +548,8 @@ async def update_preset(
|
||||
new_pw = updates.pop("encrypted_pw", None)
|
||||
if updates.get("name"):
|
||||
preset.name = updates["name"]
|
||||
if updates.get("username"):
|
||||
preset.username = updates["username"].strip() or "root"
|
||||
if new_pw:
|
||||
preset.encrypted_pw = encrypt_value(new_pw)
|
||||
|
||||
@@ -625,7 +634,16 @@ async def list_ssh_key_presets(admin: Admin = Depends(get_current_admin), db: As
|
||||
"""List all SSH key presets"""
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
presets = await repo.get_all()
|
||||
return [{"id": p.id, "name": p.name, "public_key": p.public_key or "", "created_at": str(p.created_at)} for p in presets]
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"username": p.username or "root",
|
||||
"public_key": p.public_key or "",
|
||||
"created_at": str(p.created_at),
|
||||
}
|
||||
for p in presets
|
||||
]
|
||||
|
||||
|
||||
@ssh_key_preset_router.post("/", response_model=dict, status_code=201)
|
||||
@@ -639,7 +657,12 @@ async def create_ssh_key_preset(
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
encrypted_private = encrypt_value(payload.private_key)
|
||||
preset = SshKeyPreset(name=payload.name, encrypted_private_key=encrypted_private, public_key=payload.public_key)
|
||||
preset = SshKeyPreset(
|
||||
name=payload.name,
|
||||
username=(payload.username or "root").strip() or "root",
|
||||
encrypted_private_key=encrypted_private,
|
||||
public_key=payload.public_key,
|
||||
)
|
||||
created = await repo.create(preset)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
@@ -675,6 +698,8 @@ async def update_ssh_key_preset(
|
||||
new_private_key = updates.pop("private_key", None)
|
||||
if updates.get("name"):
|
||||
preset.name = updates["name"]
|
||||
if updates.get("username"):
|
||||
preset.username = updates["username"].strip() or "root"
|
||||
if new_private_key:
|
||||
preset.encrypted_private_key = encrypt_value(new_private_key)
|
||||
if "public_key" in updates:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Poll password/SSH-key presets against a host until one SSH login succeeds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import try_ssh_login
|
||||
|
||||
logger = logging.getLogger("nexus.credential_poller")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialAttemptError:
|
||||
preset_type: str
|
||||
preset_name: str
|
||||
username: str
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollMatch:
|
||||
auth_method: str
|
||||
username: str
|
||||
preset_id: int | None = None
|
||||
ssh_key_preset_id: int | None = None
|
||||
password: str | None = None
|
||||
ssh_key_private: str | None = None
|
||||
ssh_key_public: str | None = None
|
||||
preset_name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollResult:
|
||||
success: bool
|
||||
match: PollMatch | None = None
|
||||
errors: list[CredentialAttemptError] = field(default_factory=list)
|
||||
|
||||
|
||||
def format_poll_errors(errors: list[CredentialAttemptError]) -> str:
|
||||
if not errors:
|
||||
return "无可用凭据预设或全部登录失败"
|
||||
lines = [f"[{e.preset_type}] {e.preset_name} ({e.username}): {e.error}" for e in errors]
|
||||
return "\n".join(lines)[:4000]
|
||||
|
||||
|
||||
async def poll_credentials(
|
||||
db: AsyncSession,
|
||||
host: str,
|
||||
port: int = 22,
|
||||
*,
|
||||
timeout: float = 8.0,
|
||||
) -> PollResult:
|
||||
"""Try each password preset then each SSH key preset; first success wins."""
|
||||
pw_repo = PasswordPresetRepositoryImpl(db)
|
||||
key_repo = SshKeyPresetRepositoryImpl(db)
|
||||
password_presets = await pw_repo.get_all()
|
||||
ssh_key_presets = await key_repo.get_all()
|
||||
|
||||
errors: list[CredentialAttemptError] = []
|
||||
|
||||
if not password_presets and not ssh_key_presets:
|
||||
return PollResult(success=False, errors=[
|
||||
CredentialAttemptError("system", "—", "—", "请先在凭据管理中添加密码或 SSH 密钥预设"),
|
||||
])
|
||||
|
||||
for preset in password_presets:
|
||||
username = (preset.username or "root").strip() or "root"
|
||||
try:
|
||||
password = decrypt_value(preset.encrypted_pw)
|
||||
except ValueError as e:
|
||||
err = CredentialAttemptError("password", preset.name, username, f"解密失败: {e}")
|
||||
errors.append(err)
|
||||
logger.warning("Password preset %s decrypt failed: %s", preset.id, e)
|
||||
continue
|
||||
|
||||
ok, msg = await try_ssh_login(
|
||||
host, port, username, password=password, timeout=timeout,
|
||||
)
|
||||
if ok:
|
||||
return PollResult(
|
||||
success=True,
|
||||
match=PollMatch(
|
||||
auth_method="password",
|
||||
username=username,
|
||||
preset_id=preset.id,
|
||||
password=password,
|
||||
preset_name=preset.name,
|
||||
),
|
||||
)
|
||||
errors.append(CredentialAttemptError("password", preset.name, username, msg or "认证失败"))
|
||||
|
||||
for preset in ssh_key_presets:
|
||||
username = (preset.username or "root").strip() or "root"
|
||||
try:
|
||||
private_key = decrypt_value(preset.encrypted_private_key)
|
||||
except ValueError as e:
|
||||
err = CredentialAttemptError("ssh_key", preset.name, username, f"解密失败: {e}")
|
||||
errors.append(err)
|
||||
logger.warning("SSH key preset %s decrypt failed: %s", preset.id, e)
|
||||
continue
|
||||
|
||||
ok, msg = await try_ssh_login(
|
||||
host, port, username, private_key=private_key, timeout=timeout,
|
||||
)
|
||||
if ok:
|
||||
return PollResult(
|
||||
success=True,
|
||||
match=PollMatch(
|
||||
auth_method="key",
|
||||
username=username,
|
||||
ssh_key_preset_id=preset.id,
|
||||
ssh_key_private=private_key,
|
||||
ssh_key_public=preset.public_key or "",
|
||||
preset_name=preset.name,
|
||||
),
|
||||
)
|
||||
errors.append(CredentialAttemptError("ssh_key", preset.name, username, msg or "认证失败"))
|
||||
|
||||
return PollResult(success=False, errors=errors)
|
||||
@@ -197,6 +197,7 @@ class PasswordPreset(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="预设名称")
|
||||
username = Column(String(100), default="root", comment="SSH用户名")
|
||||
encrypted_pw = Column(String(500), nullable=False, comment="加密后的密码")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
@@ -207,11 +208,35 @@ class SshKeyPreset(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="预设名称")
|
||||
username = Column(String(100), default="root", comment="SSH用户名")
|
||||
encrypted_private_key = Column(Text, nullable=False, comment="加密后的私钥内容(Fernet)")
|
||||
public_key = Column(Text, nullable=True, comment="公钥内容(不加密)")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
class PendingServer(Base):
|
||||
"""Servers awaiting successful SSH credential polling before promotion to servers table."""
|
||||
__tablename__ = "pending_servers"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="显示名称")
|
||||
domain = Column(String(255), nullable=False, comment="IP或域名")
|
||||
port = Column(Integer, default=22, comment="SSH端口")
|
||||
agent_port = Column(Integer, default=8601, comment="Agent端口")
|
||||
target_path = Column(String(500), nullable=True, comment="推送目标路径")
|
||||
category = Column(String(100), nullable=True, comment="分类")
|
||||
platform_id = Column(Integer, nullable=True, comment="平台ID")
|
||||
node_id = Column(Integer, nullable=True, comment="节点ID")
|
||||
attempts = Column(Integer, default=0, comment="轮询尝试次数")
|
||||
last_error = Column(Text, nullable=True, comment="最后一次失败摘要")
|
||||
last_attempt_at = Column(DateTime, nullable=True, comment="最后尝试时间")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_pending_servers_domain", "domain"),
|
||||
)
|
||||
|
||||
|
||||
class PushSchedule(Base):
|
||||
"""Scheduled task — cron-triggered file push OR script execution."""
|
||||
__tablename__ = "push_schedules"
|
||||
|
||||
@@ -157,6 +157,25 @@ async def run_schema_migrations():
|
||||
# Legacy single-token refresh columns (replaced by Redis refresh_tokens:*)
|
||||
"ALTER TABLE admins DROP COLUMN jwt_refresh_token",
|
||||
"ALTER TABLE admins DROP COLUMN jwt_token_expires",
|
||||
# Credential polling — preset username + pending servers queue
|
||||
"ALTER TABLE password_presets ADD COLUMN username VARCHAR(100) DEFAULT 'root' COMMENT 'SSH用户名'",
|
||||
"ALTER TABLE ssh_key_presets ADD COLUMN username VARCHAR(100) DEFAULT 'root' COMMENT 'SSH用户名'",
|
||||
"""CREATE TABLE IF NOT EXISTS `pending_servers` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '显示名称',
|
||||
`domain` VARCHAR(255) NOT NULL COMMENT 'IP或域名',
|
||||
`port` INT DEFAULT 22 COMMENT 'SSH端口',
|
||||
`agent_port` INT DEFAULT 8601 COMMENT 'Agent端口',
|
||||
`target_path` VARCHAR(500) NULL COMMENT '推送目标路径',
|
||||
`category` VARCHAR(100) NULL COMMENT '分类',
|
||||
`platform_id` INT NULL COMMENT '平台ID',
|
||||
`node_id` INT NULL COMMENT '节点ID',
|
||||
`attempts` INT DEFAULT 0 COMMENT '轮询尝试次数',
|
||||
`last_error` TEXT NULL COMMENT '最后一次失败摘要',
|
||||
`last_attempt_at` DATETIME NULL COMMENT '最后尝试时间',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX `idx_pending_servers_domain` (`domain`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
]
|
||||
async with AsyncSessionLocal() as session:
|
||||
for sql in migrations:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Nexus — PendingServer Repository (SSH poll failures queue)"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import PendingServer
|
||||
|
||||
|
||||
class PendingServerRepositoryImpl:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, id: int) -> Optional[PendingServer]:
|
||||
result = await self.session.execute(select(PendingServer).where(PendingServer.id == id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all(self) -> List[PendingServer]:
|
||||
result = await self.session.execute(
|
||||
select(PendingServer).order_by(PendingServer.last_attempt_at.desc(), PendingServer.id.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, pending: PendingServer) -> PendingServer:
|
||||
self.session.add(pending)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(pending)
|
||||
return pending
|
||||
|
||||
async def update(self, pending: PendingServer) -> PendingServer:
|
||||
await self.session.commit()
|
||||
await self.session.refresh(pending)
|
||||
return pending
|
||||
|
||||
async def delete(self, id: int) -> bool:
|
||||
row = await self.get_by_id(id)
|
||||
if not row:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def record_failure(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
domain: str,
|
||||
port: int,
|
||||
agent_port: int,
|
||||
target_path: str | None,
|
||||
category: str | None,
|
||||
platform_id: int | None,
|
||||
node_id: int | None,
|
||||
last_error: str,
|
||||
existing_id: int | None = None,
|
||||
) -> PendingServer:
|
||||
now = datetime.now(timezone.utc)
|
||||
if existing_id:
|
||||
row = await self.get_by_id(existing_id)
|
||||
if row:
|
||||
row.attempts = (row.attempts or 0) + 1
|
||||
row.last_error = last_error[:4000]
|
||||
row.last_attempt_at = now
|
||||
return await self.update(row)
|
||||
|
||||
pending = PendingServer(
|
||||
name=name,
|
||||
domain=domain,
|
||||
port=port,
|
||||
agent_port=agent_port,
|
||||
target_path=target_path,
|
||||
category=category,
|
||||
platform_id=platform_id,
|
||||
node_id=node_id,
|
||||
attempts=1,
|
||||
last_error=last_error[:4000],
|
||||
last_attempt_at=now,
|
||||
)
|
||||
return await self.create(pending)
|
||||
@@ -25,6 +25,51 @@ from server.infrastructure.database.crypto import decrypt_value
|
||||
logger = logging.getLogger("nexus.asyncssh_pool")
|
||||
|
||||
|
||||
async def try_ssh_login(
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
*,
|
||||
password: str | None = None,
|
||||
private_key: str | None = None,
|
||||
timeout: float = 8.0,
|
||||
) -> tuple[bool, str]:
|
||||
"""One-shot SSH login probe (not pooled). Returns (ok, error_message)."""
|
||||
from server.config import settings
|
||||
|
||||
if not password and not private_key:
|
||||
return False, "未提供密码或私钥"
|
||||
|
||||
connect_kwargs: dict = {
|
||||
"host": host,
|
||||
"port": port or 22,
|
||||
"username": username or "root",
|
||||
"connect_timeout": timeout,
|
||||
}
|
||||
if settings.SSH_STRICT_HOST_CHECKING.lower() in ("false", "0", "no"):
|
||||
connect_kwargs["known_hosts"] = None
|
||||
|
||||
if password:
|
||||
connect_kwargs["password"] = password
|
||||
elif private_key:
|
||||
connect_kwargs["client_keys"] = [asyncssh.import_private_key(private_key)]
|
||||
|
||||
conn: asyncssh.SSHClientConnection | None = None
|
||||
try:
|
||||
conn = await asyncssh.connect(**connect_kwargs)
|
||||
return True, ""
|
||||
except asyncssh.Error as e:
|
||||
return False, str(e)[:200]
|
||||
except Exception as e:
|
||||
return False, str(e)[:200]
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — best-effort cleanup
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PooledConnection:
|
||||
"""A tracked asyncssh connection with reference counting"""
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Tests for SSH credential polling and add-by-ip pending queue."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from server.application.services.credential_poller import (
|
||||
format_poll_errors,
|
||||
poll_credentials,
|
||||
CredentialAttemptError,
|
||||
)
|
||||
from server.domain.models import Base, PasswordPreset, PendingServer, SshKeyPreset
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession)
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_credentials_first_password_success(db_session):
|
||||
pw_repo = PasswordPresetRepositoryImpl(db_session)
|
||||
await pw_repo.create(PasswordPreset(
|
||||
name="prod-root",
|
||||
username="root",
|
||||
encrypted_pw=encrypt_value("secret1"),
|
||||
))
|
||||
await pw_repo.create(PasswordPreset(
|
||||
name="prod-admin",
|
||||
username="admin",
|
||||
encrypted_pw=encrypt_value("secret2"),
|
||||
))
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_try(host, port, username, *, password=None, private_key=None, timeout=8.0):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if username == "root" and password == "secret1":
|
||||
return True, ""
|
||||
return False, "auth failed"
|
||||
|
||||
with patch("server.application.services.credential_poller.try_ssh_login", side_effect=fake_try):
|
||||
result = await poll_credentials(db_session, "10.0.0.1", 22)
|
||||
|
||||
assert result.success is True
|
||||
assert result.match is not None
|
||||
assert result.match.auth_method == "password"
|
||||
assert result.match.username == "root"
|
||||
assert result.match.preset_name == "prod-root"
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_credentials_tries_ssh_key_after_passwords_fail(db_session):
|
||||
pw_repo = PasswordPresetRepositoryImpl(db_session)
|
||||
await pw_repo.create(PasswordPreset(
|
||||
name="bad",
|
||||
username="root",
|
||||
encrypted_pw=encrypt_value("wrong"),
|
||||
))
|
||||
key_repo = SshKeyPresetRepositoryImpl(db_session)
|
||||
await key_repo.create(SshKeyPreset(
|
||||
name="key1",
|
||||
username="deploy",
|
||||
encrypted_private_key=encrypt_value("-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----"),
|
||||
public_key="ssh-rsa AAAA",
|
||||
))
|
||||
|
||||
async def fake_try(host, port, username, *, password=None, private_key=None, timeout=8.0):
|
||||
if private_key and username == "deploy":
|
||||
return True, ""
|
||||
return False, "auth failed"
|
||||
|
||||
with patch("server.application.services.credential_poller.try_ssh_login", side_effect=fake_try):
|
||||
result = await poll_credentials(db_session, "10.0.0.2", 22)
|
||||
|
||||
assert result.success is True
|
||||
assert result.match is not None
|
||||
assert result.match.auth_method == "key"
|
||||
assert result.match.ssh_key_preset_id is not None
|
||||
assert result.match.username == "deploy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_credentials_all_fail(db_session):
|
||||
pw_repo = PasswordPresetRepositoryImpl(db_session)
|
||||
await pw_repo.create(PasswordPreset(
|
||||
name="p1",
|
||||
username="root",
|
||||
encrypted_pw=encrypt_value("x"),
|
||||
))
|
||||
|
||||
with patch(
|
||||
"server.application.services.credential_poller.try_ssh_login",
|
||||
new=AsyncMock(return_value=(False, "Permission denied")),
|
||||
):
|
||||
result = await poll_credentials(db_session, "10.0.0.3", 22)
|
||||
|
||||
assert result.success is False
|
||||
assert result.match is None
|
||||
assert len(result.errors) == 1
|
||||
assert "p1" in format_poll_errors(result.errors)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_credentials_no_presets(db_session):
|
||||
result = await poll_credentials(db_session, "10.0.0.4", 22)
|
||||
assert result.success is False
|
||||
assert any("凭据" in e.error for e in result.errors)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_server_repo_record_failure(db_session):
|
||||
repo = PendingServerRepositoryImpl(db_session)
|
||||
row = await repo.record_failure(
|
||||
name="srv",
|
||||
domain="192.168.1.5",
|
||||
port=22,
|
||||
agent_port=8601,
|
||||
target_path="",
|
||||
category=None,
|
||||
platform_id=None,
|
||||
node_id=None,
|
||||
last_error="all failed",
|
||||
)
|
||||
assert row.id
|
||||
assert row.attempts == 1
|
||||
|
||||
updated = await repo.record_failure(
|
||||
name="srv",
|
||||
domain="192.168.1.5",
|
||||
port=22,
|
||||
agent_port=8601,
|
||||
target_path="",
|
||||
category=None,
|
||||
platform_id=None,
|
||||
node_id=None,
|
||||
last_error="still failed",
|
||||
existing_id=row.id,
|
||||
)
|
||||
assert updated.attempts == 2
|
||||
assert "still failed" in (updated.last_error or "")
|
||||
|
||||
|
||||
def test_format_poll_errors_truncates():
|
||||
errors = [
|
||||
CredentialAttemptError("password", "a", "root", "e1"),
|
||||
CredentialAttemptError("ssh_key", "b", "admin", "e2"),
|
||||
]
|
||||
text = format_poll_errors(errors)
|
||||
assert "a" in text and "b" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_by_ip_endpoint_success(monkeypatch):
|
||||
"""API layer: mocked poll → server created response shape."""
|
||||
from server.api import servers as servers_api
|
||||
from server.domain.models import Server
|
||||
|
||||
mock_server = Server(
|
||||
id=1,
|
||||
name="10.1.1.1",
|
||||
domain="10.1.1.1",
|
||||
port=22,
|
||||
username="root",
|
||||
auth_method="password",
|
||||
connectivity="ok",
|
||||
)
|
||||
|
||||
async def fake_poll(*_a, **_k):
|
||||
return servers_api.__dict__.get("_PollResult") # won't work
|
||||
|
||||
match = type("M", (), {
|
||||
"auth_method": "password",
|
||||
"username": "root",
|
||||
"preset_id": 1,
|
||||
"ssh_key_preset_id": None,
|
||||
"password": "pass",
|
||||
"ssh_key_private": None,
|
||||
"ssh_key_public": "",
|
||||
"preset_name": "ok-pw",
|
||||
})()
|
||||
poll_result = type("R", (), {"success": True, "match": match, "errors": []})()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"server.application.services.credential_poller.poll_credentials",
|
||||
AsyncMock(return_value=poll_result),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
servers_api,
|
||||
"_server_exists_for_domain",
|
||||
AsyncMock(return_value=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
servers_api,
|
||||
"_create_server_from_poll",
|
||||
AsyncMock(return_value=mock_server),
|
||||
)
|
||||
monkeypatch.setattr(servers_api, "_server_to_dict", lambda s: {"id": s.id, "domain": s.domain})
|
||||
|
||||
db = MagicMock()
|
||||
admin = MagicMock(username="admin")
|
||||
request = MagicMock()
|
||||
request.client.host = "127.0.0.1"
|
||||
service = MagicMock()
|
||||
|
||||
from server.api.schemas import AddByIpRequest
|
||||
|
||||
out = await servers_api.add_server_by_ip(
|
||||
request,
|
||||
AddByIpRequest(domain="10.1.1.1", port=22),
|
||||
admin,
|
||||
service,
|
||||
db,
|
||||
)
|
||||
assert out["success"] is True
|
||||
assert out["server"]["domain"] == "10.1.1.1"
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,A as t,Ar as n,Br as r,Cr as i,F as a,N as o,Rt as s,X as c,Xn as l,_r as u,ar as d,er as f,hr as p,ir as m,k as h,nr as g,or as _,pr as v,w as y}from"./index-o0-2k-vI.js";import{o as b,t as x}from"./VContainer-BbTGUkA2.js";import{t as S}from"./VChip-B_7LyIUT.js";import{t as C}from"./VSelect-RidH-jin.js";import{t as w}from"./VDataTableServer-CYEpP3ht.js";import{n as T,t as E}from"./VRow-D9pCZkmz.js";import{t as D}from"./VSpacer-DE-o2Lgp.js";var O={class:`font-weight-medium`},k={class:`text-body-2`},A={class:`text-caption text-medium-emphasis`},j=_({__name:`AlertsPage`,setup(_){let j=b(),M=n(!1),N=n([]),P=n(0),F=n(1),I=n(null),L=n(null),R=n([{label:`今日告警`,value:`0`,color:`blue`,icon:`mdi-alert`},{label:`活跃告警`,value:`0`,color:`orange`,icon:`mdi-bell-ring`},{label:`已恢复`,value:`0`,color:`green`,icon:`mdi-check-circle`},{label:`Top 服务器`,value:`—`,color:`red`,icon:`mdi-server`}]),z=[{label:`CPU`,value:`cpu`},{label:`内存`,value:`memory`},{label:`磁盘`,value:`disk`},{label:`连接`,value:`connection`}],B=[{label:`活跃`,value:`active`},{label:`已恢复`,value:`recovered`}],V=[{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}];async function H(){try{let e=await s.get(`/alert-history/stats`);R.value[0].value=String(e.today||0),R.value[1].value=String(e.active||0),R.value[2].value=String(e.recovered||0),R.value[3].value=e.top_server||`—`}catch{j(`加载统计失败`,`error`)}}async function U(){M.value=!0;try{let e=await s.get(`/alert-history/`,{page:F.value,per_page:20,type:I.value||void 0,status:L.value||void 0});N.value=e.items||[],P.value=e.total||0}catch{N.value=[]}finally{M.value=!1}}function W(e){return e===`cpu`||e===`CPU`?`error`:e===`memory`||e===`内存`?`warning`:e===`disk`||e===`磁盘`?`info`:`primary`}function G(e){return e===`cpu`||e===`CPU`?`mdi-chip`:e===`memory`||e===`内存`?`mdi-memory`:e===`disk`||e===`磁盘`?`mdi-harddisk`:`mdi-lan-disconnect`}return v(()=>{H(),U()}),(n,s)=>(p(),f(x,{fluid:``,class:`pa-6`},{default:i(()=>[d(E,{class:`mb-4`},{default:i(()=>[(p(!0),g(l,null,u(R.value,e=>(p(),f(T,{cols:`12`,sm:`6`,lg:`3`,key:e.label},{default:i(()=>[d(y,{elevation:`0`,lines:`two`,rounded:`lg`,border:``},{default:i(()=>[d(h,null,{append:i(()=>[d(c,{color:e.color,size:`30`},{default:i(()=>[m(r(e.icon),1)]),_:2},1032,[`color`])]),default:i(()=>[d(t,{class:`text-body-small`},{default:i(()=>[m(r(e.label),1)]),_:2},1024),d(t,null,{default:i(()=>[m(r(e.value),1)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}),d(o,{elevation:`0`,border:``,rounded:`lg`},{default:i(()=>[d(a,{class:`d-flex align-center`},{default:i(()=>[s[5]||=m(` 告警历史 `,-1),d(D),d(C,{modelValue:I.value,"onUpdate:modelValue":[s[0]||=e=>I.value=e,s[1]||=e=>{F.value=1,U()}],items:z,"item-title":`label`,"item-value":`value`,label:`类型`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},clearable:``},null,8,[`modelValue`]),d(C,{modelValue:L.value,"onUpdate:modelValue":[s[2]||=e=>L.value=e,s[3]||=e=>{F.value=1,U()}],items:B,"item-title":`label`,"item-value":`value`,label:`状态`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},class:`ml-3`,clearable:``},null,8,[`modelValue`])]),_:1}),d(w,{items:N.value,headers:V,"items-length":P.value,loading:M.value,page:F.value,"items-per-page":20,hover:``,density:`comfortable`,"onUpdate:page":s[4]||=e=>{F.value=e,U()}},{"item.alert_type":i(({item:e})=>[d(S,{color:W(e.alert_type),size:`small`,variant:`tonal`,label:``},{prepend:i(()=>[d(c,{size:`12`},{default:i(()=>[m(r(G(e.alert_type)),1)]),_:2},1024)]),default:i(()=>[m(` `+r(e.alert_type),1)]),_:2},1032,[`color`])]),"item.server_name":i(({item:t})=>[e(`span`,O,r(t.server_name),1)]),"item.value":i(({item:t})=>[e(`span`,k,r(t.value),1)]),"item.is_recovery":i(({item:e})=>[e.is_recovery?(p(),f(S,{key:0,color:`success`,size:`small`,variant:`tonal`,label:``},{default:i(()=>[...s[6]||=[m(`已恢复`,-1)]]),_:1})):(p(),f(S,{key:1,color:`error`,size:`small`,variant:`tonal`,label:``},{default:i(()=>[...s[7]||=[m(`告警中`,-1)]]),_:1}))]),"item.created_at":i(({item:t})=>[e(`span`,A,r(t.created_at),1)]),"no-data":i(()=>[...s[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1})]),_:1}))}});export{j as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,Ir as a,N as o,Rt as s,ar as c,er as l,hr as u,ir as d,or as f,pr as p,t as m}from"./index-o0-2k-vI.js";import{o as h,t as g}from"./VContainer-BbTGUkA2.js";import{t as _}from"./VChip-B_7LyIUT.js";import{t as v}from"./VSelect-RidH-jin.js";import{t as y}from"./VDataTableServer-CYEpP3ht.js";import{t as b}from"./VSpacer-DE-o2Lgp.js";import{a as x,n as S,r as C,t as w}from"./auditNormalize-BHJPJFP9.js";var T={class:`font-weight-medium`},E={class:`text-body-2`},D={class:`text-body-2 text-medium-emphasis text-truncate`,style:{"max-width":`300px`,display:`inline-block`}},O={class:`text-caption text-medium-emphasis`},k={class:`text-caption text-medium-emphasis`},A=f({__name:`AuditPage`,setup(f){let A=h(),j=t(!1),M=t([]),N=t(0),P=t(1),F=t(null),I=t(``),L=t(``),R=S,z=[{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}];async function B(){j.value=!0;try{let e=(P.value-1)*50,t=await s.get(`/audit/`,{limit:50,offset:e,action:F.value||void 0,admin_username:I.value||void 0,date_from:L.value||void 0});M.value=w(t.items||[]),N.value=t.total||0}catch{M.value=[],A(`加载审计日志失败`,`error`)}finally{j.value=!1}}function V(e){return e.startsWith(`create`)||e===`login`?`success`:e.startsWith(`update`)?`info`:e.startsWith(`delete`)||e===`logout`?`error`:e.includes(`execute`)||e.includes(`push`)||e.includes(`retry`)?`warning`:`primary`}return p(()=>B()),(t,s)=>(u(),l(g,{fluid:``,class:`pa-6`},{default:r(()=>[c(o,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[c(i,{class:`d-flex align-center`},{default:r(()=>[s[7]||=d(` 审计日志 `,-1),c(b),c(v,{modelValue:F.value,"onUpdate:modelValue":[s[0]||=e=>F.value=e,s[1]||=e=>{P.value=1,B()}],items:a(R),"item-title":`label`,"item-value":`value`,label:`操作类型`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`200px`},clearable:``},null,8,[`modelValue`,`items`]),c(m,{modelValue:I.value,"onUpdate:modelValue":[s[2]||=e=>I.value=e,s[3]||=e=>{P.value=1,B()}],"prepend-inner-icon":`mdi-account`,label:`用户`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},class:`ml-3`,clearable:``},null,8,[`modelValue`]),c(m,{modelValue:L.value,"onUpdate:modelValue":[s[4]||=e=>L.value=e,s[5]||=e=>{P.value=1,B()}],"prepend-inner-icon":`mdi-calendar`,label:`日期`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`},class:`ml-3`,type:`date`,clearable:``},null,8,[`modelValue`])]),_:1}),c(y,{items:M.value,headers:z,"items-length":N.value,loading:j.value,page:P.value,"items-per-page":50,hover:``,density:`compact`,"onUpdate:page":s[6]||=e=>{P.value=e,B()}},{"item.action":r(({item:e})=>[c(_,{color:V(e.action),size:`small`,variant:`tonal`,label:``},{default:r(()=>[d(n(a(C)(e.action)),1)]),_:2},1032,[`color`])]),"item.admin_username":r(({item:t})=>[e(`span`,T,n(t.admin_username),1)]),"item.target":r(({item:t})=>[e(`span`,E,n(a(x)(t.resource_type))+n(t.resource_id?` #${t.resource_id}`:``),1)]),"item.detail":r(({item:t})=>[e(`span`,D,n(t.detail||`—`),1)]),"item.ip_address":r(({item:t})=>[e(`span`,O,n(t.ip_address||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,k,n(t.created_at),1)]),"no-data":r(()=>[...s[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1})]),_:1}))}});export{A as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,Ir as a,M as o,N as s,Rt as c,ar as l,er as u,hr as d,ir as f,or as p,pr as m}from"./index-o0-2k-vI.js";import{o as h,t as g}from"./VContainer-BbTGUkA2.js";import{t as _}from"./VChip-B_7LyIUT.js";import{t as v}from"./VSelect-RidH-jin.js";import{t as y}from"./VDataTableServer-CYEpP3ht.js";import{t as b}from"./VSpacer-DE-o2Lgp.js";import{t as x}from"./apiError-nzQ5DKV3.js";import{t as S}from"./useServerList-CY5z3iKc.js";var C={class:`text-body-2`},w={class:`text-medium-emphasis`},T={class:`text-medium-emphasis`},E={class:`text-caption text-medium-emphasis`},D={class:`font-weight-medium`},O={class:`text-caption text-medium-emphasis`},k=p({__name:`CommandsPage`,setup(p){let k=h(),{servers:A,loadServers:j}=S(),M=t(!1),N=t(`commands`),P=t(1),F=t(0),I=t(30),L=t(null),R=t([]),z=t([]),B=[{title:`命令`,key:`command`},{title:`服务器`,key:`server_name`},{title:`用户`,key:`admin_username`,width:100},{title:`时间`,key:`created_at`,width:160}],V=[{title:`服务器`,key:`server_name`},{title:`状态`,key:`status`,width:80},{title:`来源`,key:`remote_addr`,width:120},{title:`开始时间`,key:`started_at`,width:160}];async function H(){M.value=!0;try{if(N.value===`commands`){let e=await c.getList(`/assets/command-logs`,{server_id:L.value||void 0,page:P.value,per_page:I.value});R.value=e.items,F.value=e.total}else{let e=await c.getList(`/assets/ssh-sessions`,{server_id:L.value||void 0,page:P.value,per_page:I.value});z.value=e.items,F.value=e.total}}catch(e){R.value=[],z.value=[],k(x(e,`加载命令日志失败`),`error`)}finally{M.value=!1}}return m(()=>{j(),H()}),(t,c)=>(d(),u(g,{fluid:``,class:`pa-6`},{default:r(()=>[l(s,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[l(i,{class:`d-flex align-center`},{default:r(()=>[c[4]||=f(` 命令日志 `,-1),l(b),l(v,{modelValue:N.value,"onUpdate:modelValue":[c[0]||=e=>N.value=e,H],items:[{title:`命令视图`,value:`commands`},{title:`会话视图`,value:`sessions`}],"item-title":`title`,"item-value":`value`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`}},null,8,[`modelValue`]),l(v,{modelValue:L.value,"onUpdate:modelValue":[c[1]||=e=>L.value=e,H],items:a(A),"item-title":`name`,"item-value":`id`,label:`服务器`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`180px`},class:`ml-3`,clearable:``},null,8,[`modelValue`,`items`])]),_:1}),l(o),N.value===`commands`?(d(),u(y,{key:0,items:R.value,headers:B,"items-length":F.value,loading:M.value,page:P.value,"items-per-page":30,hover:``,density:`compact`,"onUpdate:page":c[2]||=e=>{P.value=e,H()}},{"item.command":r(({item:t})=>[e(`code`,C,n(t.command),1)]),"item.server_name":r(({item:t})=>[e(`span`,w,n(t.server_name||`—`),1)]),"item.admin_username":r(({item:t})=>[e(`span`,T,n(t.admin_username||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,E,n(t.created_at),1)]),"no-data":r(()=>[...c[5]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])):(d(),u(y,{key:1,items:z.value,headers:V,"items-length":F.value,loading:M.value,page:P.value,"items-per-page":30,hover:``,density:`compact`,"onUpdate:page":c[3]||=e=>{P.value=e,H()}},{"item.server_name":r(({item:t})=>[e(`span`,D,n(t.server_name||`—`),1)]),"item.status":r(({item:e})=>[l(_,{color:e.status===`closed`?`grey`:`success`,size:`small`,variant:`tonal`,label:``},{default:r(()=>[f(n(e.status),1)]),_:2},1032,[`color`])]),"item.started_at":r(({item:t})=>[e(`span`,O,n(t.started_at),1)]),"no-data":r(()=>[...c[6]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`]))]),_:1})]),_:1}))}});export{k as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,Ft as i,Ht as a,It as o,N as s,Pt as c,Qn as l,R as u,Rr as d,Rt as f,Vt as p,Yn as m,ar as h,c as g,cn as _,dn as v,er as y,fn as b,hr as x,ir as S,l as C,mr as w,nn as T,nr as E,or as D,pr as O,t as k,tr as A,v as j,xr as M,zr as N}from"./index-o0-2k-vI.js";import{t as P}from"./VChip-B_7LyIUT.js";import{t as F}from"./_plugin-vue_export-helper-BDNMzG2s.js";import{t as I}from"./VAlert-CJEBMMcd.js";var L=b({...v(),...C()},`VForm`),R=_()({name:`VForm`,props:L(),emits:{"update:modelValue":e=>!0,submit:e=>!0},setup(n,{slots:r,emit:i}){let a=g(n),o=t();function s(e){e.preventDefault(),a.reset()}function c(e){let t=e,n=a.validate();t.then=n.then.bind(n),t.catch=n.catch.bind(n),t.finally=n.finally.bind(n),i(`submit`,t),t.defaultPrevented||n.then(({valid:e})=>{e&&o.value?.submit()}),t.preventDefault()}return T(()=>e(`form`,{ref:o,class:d([`v-form`,n.class]),style:N(n.style),novalidate:!0,onReset:s,onSubmit:c},[r.default?.({get errors(){return a.errors.value},get isDisabled(){return a.isDisabled.value},get isReadonly(){return a.isReadonly.value},get isValidating(){return a.isValidating.value},get isValid(){return a.isValid.value},get items(){return a.items.value},validate:a.validate,reset:a.reset,resetValidation:a.resetValidation})])),j(a,o)}}),z={class:`login-page`},B={class:`login-center`},V={key:2,class:`text-center mt-4`},H=F(D({__name:`LoginPage`,setup(d){let g=p(),_=a(),v=c(),b=t(``),C=t(``),T=t(``),D=t(!1),j=t(!1),F=t(``),L=t(!1),H=t(0),U=t(null),W=t([]),G=t(0),K=null,q=l(()=>W.value.length===0?``:W.value[G.value]);async function J(){try{let e=await f.get(`/settings/bing-wallpapers`);e?.urls?.length&&(W.value=e.urls,G.value=Math.floor(Math.random()*e.urls.length))}catch{}}function Y(){K&&clearInterval(K),!(W.value.length<=1)&&(K=setInterval(()=>{G.value=(G.value+1)%W.value.length},36e5))}O(async()=>{await J(),Y()}),w(()=>{K&&clearInterval(K),Z&&clearInterval(Z)});let X=t(Date.now()),Z=null,Q=l(()=>{if(!U.value)return 0;let e=new Date(U.value).getTime()-X.value;return Math.max(0,Math.ceil(e/6e4))});M(U,e=>{Z&&=(clearInterval(Z),null),e&&(Z=setInterval(()=>{X.value=Date.now()},3e4))});async function $(){if(!b.value||!C.value){F.value=`请输入用户名和密码`;return}j.value=!0,F.value=``;try{await v.login(b.value,C.value,L.value?T.value:void 0);let e=g.query.redirect||`/`,t=e.startsWith(`/`)&&!e.startsWith(`//`)?e:`/`;_.push(t)}catch(e){e instanceof o?(L.value=!0,F.value=e.message):e instanceof i?e.status===429?(U.value=new Date(Date.now()+900*1e3).toISOString(),F.value=`登录尝试过多,账户已锁定 15 分钟`):(H.value++,F.value=e.message):F.value=`网络错误,请重试`}finally{j.value=!1}}return(t,i)=>(x(),E(`div`,z,[e(`div`,{class:`wallpaper-bg`,style:N({backgroundImage:`url(${q.value})`})},null,4),i[6]||=e(`div`,{class:`wallpaper-overlay`},null,-1),e(`div`,B,[h(s,{elevation:`0`,rounded:`xl`,class:`login-card pa-10`,border:``},{default:r(()=>[F.value?(x(),y(I,{key:0,type:`error`,density:`compact`,class:`mb-4`,closable:``,"onClick:close":i[0]||=e=>F.value=``,rounded:`lg`,variant:`tonal`},{default:r(()=>[S(n(F.value),1)]),_:1})):A(``,!0),U.value?(x(),y(I,{key:1,type:`warning`,density:`compact`,class:`mb-4`,rounded:`lg`,variant:`tonal`},{default:r(()=>[S(` 账户已锁定,请 `+n(Q.value)+` 分钟后重试 `,1)]),_:1})):A(``,!0),h(R,{onSubmit:m($,[`prevent`])},{default:r(()=>[h(k,{modelValue:b.value,"onUpdate:modelValue":i[1]||=e=>b.value=e,label:`用户名`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-account-outline`,disabled:j.value,class:`mb-4`,autocomplete:`username`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`disabled`]),h(k,{modelValue:C.value,"onUpdate:modelValue":i[2]||=e=>C.value=e,label:`密码`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-lock-outline`,"append-inner-icon":D.value?`mdi-eye`:`mdi-eye-off`,type:D.value?`text`:`password`,"onClick:appendInner":i[3]||=e=>D.value=!D.value,disabled:j.value,class:`mb-4`,autocomplete:`current-password`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`append-inner-icon`,`type`,`disabled`]),L.value?(x(),y(k,{key:0,modelValue:T.value,"onUpdate:modelValue":i[4]||=e=>T.value=e,label:`TOTP 验证码`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-shield-key-outline`,disabled:j.value,class:`mb-4`,autocomplete:`one-time-code`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`disabled`])):A(``,!0),h(u,{type:`submit`,color:`primary`,variant:`flat`,block:``,size:`large`,loading:j.value,disabled:!!U.value,rounded:`lg`,class:`mt-2`,height:`48`},{default:r(()=>[...i[5]||=[S(` 登录 `,-1)]]),_:1},8,[`loading`,`disabled`])]),_:1}),H.value>0&&!U.value?(x(),E(`div`,V,[h(P,{size:`small`,variant:`tonal`,color:`warning`,label:``},{default:r(()=>[S(` 已失败 `+n(H.value)+` 次 `,1)]),_:1})])):A(``,!0)]),_:1})])]))}}),[[`__scopeId`,`data-v-c0c6156f`]]);export{H as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,I as a,N as o,P as s,R as c,Rt as l,ar as u,er as d,hr as f,ir as p,or as m,pr as h,tr as g}from"./index-o0-2k-vI.js";import{o as _,t as v}from"./VContainer-BbTGUkA2.js";import{t as y}from"./VChip-B_7LyIUT.js";import{t as b}from"./VSelect-RidH-jin.js";import{t as x}from"./VDataTableServer-CYEpP3ht.js";import{t as S}from"./VSpacer-DE-o2Lgp.js";import{t as C}from"./VDialog-nLwlI-ms.js";var w={class:`font-weight-medium text-truncate d-inline-block`,style:{"max-width":`240px`}},T={class:`text-medium-emphasis`},E={class:`text-medium-emphasis`},D={class:`text-caption text-medium-emphasis`},O={class:`text-caption text-medium-emphasis`},k=m({__name:`RetriesPage`,setup(m){let k=_(),A=t(!1),j=t([]),M=t(0),N=t(1),P=t(null),F=t(!1),I=t(null),L=[{label:`待重试`,value:`pending`},{label:`重试中`,value:`retrying`},{label:`成功`,value:`success`},{label:`失败`,value:`failed`}],R=[{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`,sortable:!1}];async function z(){A.value=!0;try{let e=(await l.getList(`/retries/`,{limit:200})).items||[];P.value&&(e=e.filter(e=>e.status===P.value)),M.value=e.length;let t=(N.value-1)*20;j.value=e.slice(t,t+20)}catch(e){j.value=[],M.value=0,k(e instanceof Error?e.message:`加载重试队列失败`,`error`)}finally{A.value=!1}}async function B(e){try{await l.post(`/retries/${e.id}/retry`),z(),k(`重试已触发`)}catch(e){k(e.message||`操作失败`,`error`)}}function V(e){I.value=e.id,F.value=!0}async function H(){if(I.value)try{await l.delete(`/retries/${I.value}`),F.value=!1,z(),k(`已删除`)}catch(e){k(e.message||`删除失败`,`error`)}}function U(e){return e===`success`?`success`:e===`failed`?`error`:e===`retrying`?`warning`:`info`}function W(e){return e===`pending`?`待重试`:e===`retrying`?`重试中`:e===`success`?`成功`:e===`failed`?`失败`:e}function G(e){return e.source_path&&e.target_path?`${e.source_path} → ${e.target_path}`:e.source_path||e.target_path||`文件推送`}return h(()=>z()),(t,l)=>(f(),d(v,{fluid:``,class:`pa-6`},{default:r(()=>[u(o,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[u(i,{class:`d-flex align-center text-h6`},{default:r(()=>[l[5]||=p(` 重试队列 `,-1),u(S),u(b,{modelValue:P.value,"onUpdate:modelValue":[l[0]||=e=>P.value=e,l[1]||=e=>{N.value=1,z()}],items:L,"item-title":`label`,"item-value":`value`,label:`状态筛选`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`},clearable:``},null,8,[`modelValue`])]),_:1}),u(x,{items:j.value,headers:R,"items-length":M.value,loading:A.value,page:N.value,"items-per-page":20,hover:``,density:`comfortable`,"onUpdate:page":l[2]||=e=>{N.value=e,z()}},{"item.status":r(({item:e})=>[u(y,{color:U(e.status),size:`x-small`,variant:`tonal`,label:``,border:`sm`},{default:r(()=>[p(n(W(e.status)),1)]),_:2},1032,[`color`])]),"item.operation":r(({item:t})=>[e(`span`,w,n(G(t)),1)]),"item.server_name":r(({item:t})=>[e(`span`,T,n(t.server_name||`—`),1)]),"item.retry_count":r(({item:t})=>[e(`span`,E,n(t.retry_count||0)+` / `+n(t.max_retries||3),1)]),"item.next_retry_at":r(({item:t})=>[e(`span`,D,n(t.next_retry_at||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,O,n(t.created_at),1)]),"item.actions":r(({item:e})=>[e.status===`pending`||e.status===`failed`?(f(),d(c,{key:0,variant:`text`,size:`x-small`,color:`primary`,density:`compact`,onClick:t=>B(e)},{default:r(()=>[...l[6]||=[p(` 重试 `,-1)]]),_:1},8,[`onClick`])):g(``,!0),u(c,{variant:`text`,size:`x-small`,color:`error`,density:`compact`,onClick:t=>V(e)},{default:r(()=>[...l[7]||=[p(` 删除 `,-1)]]),_:1},8,[`onClick`])]),"no-data":r(()=>[...l[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1}),u(C,{modelValue:F.value,"onUpdate:modelValue":l[4]||=e=>F.value=e,"max-width":`400`},{default:r(()=>[u(o,{border:``},{default:r(()=>[u(i,null,{default:r(()=>[...l[9]||=[p(`确认删除`,-1)]]),_:1}),u(s,null,{default:r(()=>[...l[10]||=[p(`确定要删除此重试任务吗?`,-1)]]),_:1}),u(a,null,{default:r(()=>[u(S),u(c,{variant:`text`,onClick:l[3]||=e=>F.value=!1},{default:r(()=>[...l[11]||=[p(`取消`,-1)]]),_:1}),u(c,{color:`error`,variant:`flat`,onClick:H},{default:r(()=>[...l[12]||=[p(`删除`,-1)]]),_:1})]),_:1})]),_:1})]),_:1},8,[`modelValue`])]),_:1}))}});export{k as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,L as i,N as a,P as o,Rr as s,Rt as c,X as l,Xn as u,_r as d,ar as f,er as p,hr as m,mr as h,nr as g,or as _}from"./index-o0-2k-vI.js";import{n as v,t as y}from"./VRow-D9pCZkmz.js";import{t as b}from"./_plugin-vue_export-helper-BDNMzG2s.js";function x(){let e=t([]),n=t(!1),r=t(1),i=t(20),a=t(0),o=t(``);async function s(){n.value=!0;try{let t=await c.get(`/servers/`,{page:r.value,per_page:i.value,search:o.value||void 0});e.value=t.items||[],a.value=t.total||0}catch(t){throw e.value=[],a.value=0,t}finally{n.value=!1}}let l;h(()=>clearTimeout(l));function u(){clearTimeout(l),l=setTimeout(()=>{r.value=1,s()},300)}function d(e){r.value=e,s()}function f(e){i.value=e,r.value=1,s()}return{servers:e,loading:n,page:r,itemsPerPage:i,total:a,search:o,loadServers:s,onSearch:u,onPageChange:d,onItemsPerPageChange:f}}function S(e){switch(e){case`online`:return`success`;case`offline`:return`error`;default:return`warning`}}function C(e){switch(e){case`online`:return`在线`;case`offline`:return`离线`;default:return`未知`}}function w(e){if(!e)return`—`;let t=new Date(e);if(isNaN(t.getTime()))return`—`;let n=Date.now()-t.getTime();return n<6e4?`刚刚`:n<36e5?`${Math.floor(n/6e4)} 分钟前`:n<864e5?`${Math.floor(n/36e5)} 小时前`:`${Math.floor(n/864e5)} 天前`}var T={class:`text-caption text-medium-emphasis mb-1`,style:{"font-size":`14px`,"letter-spacing":`0.5px`,"text-transform":`uppercase`}},E=b(_({__name:`StatCardsRow`,props:{items:{}},setup(t){return(c,h)=>(m(),p(y,null,{default:r(()=>[(m(!0),g(u,null,d(t.items,(t,c)=>(m(),p(v,{key:c,cols:`6`,sm:`3`},{default:r(()=>[f(a,{elevation:`0`,rounded:`xl`,border:``,class:`stat-card`},{default:r(()=>[f(o,{class:`pa-4 d-flex align-center justify-space-between`},{default:r(()=>[e(`div`,null,[e(`div`,T,n(t.subtitle),1),e(`div`,{class:s([`font-weight-bold`,`text-`+t.color]),style:{"font-size":`36px`,"line-height":`1.1`}},n(t.title),3)]),f(i,{color:t.color,size:`48`,rounded:`lg`,class:`stat-icon`},{default:r(()=>[f(l,{icon:t.icon,size:`24`,color:`white`},null,8,[`icon`])]),_:2},1032,[`color`])]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}))}}),[[`__scopeId`,`data-v-68b984da`]]);export{x as a,C as i,w as n,S as r,E as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$n as e,$t as t,Ct as n,Gt as r,Nr as i,Qn as a,R as o,Rr as s,St as c,Tt as l,U as u,W as d,Wt as f,X as p,Zt as m,ar as h,at as g,bt as _,cn as v,ct as y,dn as b,dt as x,fn as S,ft as C,it as w,jt as T,lr as E,on as D,ot as O,pt as k,st as A,tn as j,ut as M,wt as N,yt as P,zr as F}from"./index-o0-2k-vI.js";var I=D(`v-alert-title`),L=S({iconSize:[Number,String],iconSizes:{type:Array,default:()=>[[`x-small`,10],[`small`,16],[`default`,24],[`large`,28],[`x-large`,32]]}},`iconSize`);function R(e,t){return{iconSize:a(()=>{let n=new Map(e.iconSizes),r=e.iconSize??t()??`default`;return n.has(r)?n.get(r):r})}}var z=[`success`,`info`,`warning`,`error`],B=S({border:{type:[Boolean,String],validator:e=>typeof e==`boolean`||[`top`,`end`,`bottom`,`start`].includes(e)},borderColor:String,closable:Boolean,closeIcon:{type:j,default:`$close`},closeLabel:{type:String,default:`$vuetify.close`},icon:{type:[Boolean,String,Function,Object],default:null},modelValue:{type:Boolean,default:!0},prominent:Boolean,title:String,text:String,type:{type:String,validator:e=>z.includes(e)},...b(),...A(),...n(),...C(),...L(),...M(),...u(),...P(),...T(),...f(),...g({variant:`flat`})},`VAlert`),V=v()({name:`VAlert`,props:B(),emits:{"click:close":e=>!0,"update:modelValue":e=>!0},setup(n,{emit:a,slots:u}){let f=t(n,`modelValue`),g=i(()=>{if(n.icon!==!1)return n.type?n.icon??`$${n.type}`:n.icon}),{iconSize:v}=R(n,()=>n.prominent?44:void 0),{themeClasses:b}=r(n),{colorClasses:S,colorStyles:C,variantClasses:T}=O(()=>({color:n.color??n.type,variant:n.variant})),{densityClasses:D}=y(n),{dimensionStyles:A}=N(n),{elevationClasses:j}=k(n),{locationStyles:M}=x(n),{positionClasses:P}=d(n),{roundedClasses:L}=_(n),{textColorClasses:z,textColorStyles:B}=c(()=>n.borderColor),{t:V}=m(),H=i(()=>({"aria-label":V(n.closeLabel),onClick(e){f.value=!1,a(`click:close`,e)}}));return()=>{let t=!!(u.prepend||g.value),r=!!(u.title||n.title),i=!!(u.close||n.closable),a={density:n.density,icon:g.value,size:n.iconSize||n.prominent?v.value:void 0};return f.value&&h(n.tag,{class:s([`v-alert`,n.border&&{"v-alert--border":!!n.border,[`v-alert--border-${n.border===!0?`start`:n.border}`]:!0},{"v-alert--prominent":n.prominent},b.value,S.value,D.value,j.value,P.value,L.value,T.value,n.class]),style:F([C.value,A.value,M.value,n.style]),role:`alert`},{default:()=>[w(!1,`v-alert`),n.border&&e(`div`,{key:`border`,class:s([`v-alert__border`,z.value]),style:F(B.value)},null),t&&e(`div`,{key:`prepend`,class:`v-alert__prepend`},[u.prepend?h(l,{key:`prepend-defaults`,disabled:!g.value,defaults:{VIcon:{...a}}},u.prepend):h(p,E({key:`prepend-icon`},a),null)]),e(`div`,{class:`v-alert__content`},[r&&h(I,{key:`title`},{default:()=>[u.title?.()??n.title]}),u.text?.()??n.text,u.default?.()]),u.append&&e(`div`,{key:`append`,class:`v-alert__append`},[u.append()]),i&&e(`div`,{key:`close`,class:`v-alert__close`},[u.close?h(l,{key:`close-defaults`,defaults:{VBtn:{icon:n.closeIcon,size:`x-small`,variant:`text`}}},{default:()=>[u.close?.({props:H.value})]}):h(o,E({key:`close-btn`,icon:n.closeIcon,size:`x-small`,variant:`text`},H.value),null)])]})}}});export{V as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{$t as e,Ar as t,Rn as n,ar as r,br as i,cn as a,fn as o,lr as s,nn as c,o as l,p as u,s as d,v as f,wn as p}from"./index-o0-2k-vI.js";import{f as m,p as h}from"./VSelect-RidH-jin.js";var g=o({...n(d(),[`direction`]),...n(h(),[`inline`])},`VCheckbox`),_=a()({name:`VCheckbox`,inheritAttrs:!1,props:g(),emits:{"update:modelValue":e=>!0,"update:focused":e=>!0},setup(n,{attrs:a,slots:o}){let d=e(n,`modelValue`),{isFocused:h,focus:g,blur:_}=u(n),v=t(),y=i();return c(()=>{let[e,t]=p(a),i=l.filterProps(n),c=m.filterProps(n);return r(l,s({ref:v,class:[`v-checkbox`,n.class]},e,i,{modelValue:d.value,"onUpdate:modelValue":e=>d.value=e,id:n.id||`checkbox-${y}`,focused:h.value,style:n.style}),{...o,default:({id:e,messagesId:n,isDisabled:i,isReadonly:a,isValid:l})=>r(m,s(c,{id:e.value,"aria-describedby":n.value,disabled:i.value,readonly:a.value},t,{error:l.value===!1,modelValue:d.value,"onUpdate:modelValue":e=>d.value=e,onFocus:g,onBlur:_}),o)})}),f({},v)}});export{_ as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as e,a as t,b as n,c as r,d as i,f as a,g as o,h as s,i as c,l,m as u,n as d,o as f,p,r as m,s as h,u as ee,v as te,y as g}from"./VDataTable-CA2-7Joy.js";import{$n as _,M as v,Nr as y,Pr as b,Qn as x,Rn as S,Xn as C,ar as w,cn as T,fn as E,gr as ne,ln as D,lr as O,nn as k}from"./index-o0-2k-vI.js";var A=E({itemsLength:{type:[Number,String],required:!0},...g(),...d(),...e()},`VDataTableServer`),j=T()({name:`VDataTableServer`,props:A(),emits:{"update:modelValue":e=>!0,"update:page":e=>!0,"update:itemsPerPage":e=>!0,"update:sortBy":e=>!0,"update:options":e=>!0,"update:expanded":e=>!0,"update:groupBy":e=>!0},setup(e,{attrs:d,slots:g}){let{groupBy:T}=r(e),{initialSortOrder:E,sortBy:A,multiSort:j,mustSort:M}=a(e),{page:N,itemsPerPage:P}=te(e),{disableSort:F}=b(e),I=x(()=>parseInt(e.itemsLength,10)),{columns:L,headers:R}=s(e,{groupBy:T,showSelect:y(()=>e.showSelect),showExpand:y(()=>e.showExpand)}),{items:z}=c(e,L),{toggleSort:B}=p({initialSortOrder:E,sortBy:A,multiSort:j,mustSort:M,page:N}),{opened:V,isGroupOpen:H,toggleGroup:re,extractRows:U}=l({groupBy:T,sortBy:A,disableSort:F}),{pageCount:W,setItemsPerPage:G,prevPage:K,nextPage:q,setPage:J}=n({page:N,itemsPerPage:P,itemsLength:I}),{flatItems:Y}=ee(z,T,V,()=>!!g[`group-summary`]),{isSelected:X,select:ie,selectAll:ae,toggleSelect:oe,someSelected:se,allSelected:ce}=u(e,{allItems:z,currentPage:z}),{isExpanded:Z,toggleExpand:le}=h(e),Q=x(()=>U(z.value));m({page:N,itemsPerPage:P,sortBy:A,groupBy:T,search:y(()=>e.search)}),ne(`v-data-table`,{toggleSort:B,sortBy:A}),D({VDataTableRows:{hideNoData:y(()=>e.hideNoData),noDataText:y(()=>e.noDataText),loading:y(()=>e.loading),loadingText:y(()=>e.loadingText)}});let $=x(()=>({page:N.value,itemsPerPage:P.value,sortBy:A.value,pageCount:W.value,toggleSort:B,setItemsPerPage:G,prevPage:K,nextPage:q,setPage:J,someSelected:se.value,allSelected:ce.value,isSelected:X,select:ie,selectAll:ae,toggleSelect:oe,isExpanded:Z,toggleExpand:le,isGroupOpen:H,toggleGroup:re,items:Q.value.map(e=>e.raw),internalItems:Q.value,groupedItems:Y.value,columns:L.value,headers:R.value}));k(()=>{let n=o.filterProps(e),r=i.filterProps(S(e,[`multiSort`])),a=f.filterProps(e),s=t.filterProps(e);return w(t,O({class:[`v-data-table`,{"v-data-table--loading":e.loading},e.class],style:e.style},s,{fixedHeader:e.fixedHeader||e.sticky}),{top:()=>g.top?.($.value),default:()=>g.default?g.default($.value):_(C,null,[g.colgroup?.($.value),!e.hideDefaultHeader&&_(`thead`,{key:`thead`,class:`v-data-table__thead`,role:`rowgroup`},[w(i,O(r,{multiSort:!!e.multiSort}),g)]),g.thead?.($.value),!e.hideDefaultBody&&_(`tbody`,{class:`v-data-table__tbody`,role:`rowgroup`},[g[`body.prepend`]?.($.value),g.body?g.body($.value):w(f,O(d,a,{items:Y.value}),g),g[`body.append`]?.($.value)]),g.tbody?.($.value),g.tfoot?.($.value)]),bottom:()=>g.bottom?g.bottom($.value):!e.hideDefaultFooter&&_(C,null,[w(v,null,null),w(o,n,{prepend:g[`footer.prepend`]})])})})}});export{j as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{$t as e,Ar as t,At as n,Rn as r,Tt as i,ar as a,b as o,cn as s,fn as c,lr as l,nn as u,ur as d,v as f,x as p,xr as m,y as h}from"./index-o0-2k-vI.js";var g=c({fullscreen:Boolean,scrollable:Boolean,...r(o({captureFocus:!0,origin:`center center`,scrollStrategy:`block`,transition:{component:n},zIndex:2400,retainFocus:!0}),[`disableInitialFocus`])},`VDialog`),_=s()({name:`VDialog`,props:g(),emits:{"update:modelValue":e=>!0,afterEnter:()=>!0,afterLeave:()=>!0},setup(n,{emit:r,slots:o}){let s=e(n,`modelValue`),{scopeId:c}=p(),g=t();function _(){r(`afterEnter`),(n.scrim||n.retainFocus)&&g.value?.contentEl&&!g.value.contentEl.contains(document.activeElement)&&g.value.contentEl.focus({preventScroll:!0})}function v(){r(`afterLeave`)}return m(s,async e=>{e||(await d(),g.value.activatorEl?.focus({preventScroll:!0}))}),u(()=>{let e=h.filterProps(n),t=l({"aria-haspopup":`dialog`},n.activatorProps),r=l({tabindex:-1},n.contentProps);return a(h,l({ref:g,class:[`v-dialog`,{"v-dialog--fullscreen":n.fullscreen,"v-dialog--scrollable":n.scrollable},n.class],style:n.style},e,{modelValue:s.value,"onUpdate:modelValue":e=>s.value=e,"aria-modal":`true`,activatorProps:t,contentProps:r,height:n.fullscreen?void 0:n.height,width:n.fullscreen?void 0:n.width,maxHeight:n.fullscreen?void 0:n.maxHeight,maxWidth:n.fullscreen?void 0:n.maxWidth,role:`dialog`,onAfterEnter:_,onAfterLeave:v},c),{activator:o.activator,default:(...e)=>a(i,{root:`VDialog`},{default:()=>[o.default?.(...e)]})})}),f({},g)}});export{_ as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{Fn as e,Gn as t,Jt as n,Lr as r,Qn as i,cn as a,dn as o,fn as s,jt as c,sr as l,st as u,yn as d}from"./index-o0-2k-vI.js";var f=n.reduce((e,t)=>(e[t]={type:[Boolean,String,Number],default:!1},e),{}),p=n.reduce((e,t)=>{let n=`offset`+r(t);return e[n]={type:[String,Number],default:null},e},{}),m={col:e(f),offset:e(p),order:[`order`,`orderSm`,`orderMd`,`orderLg`,`orderXl`,`orderXxl`]};function h(e){if(typeof e==`string`&&e.includes(`/`)){let[t,n]=e.split(`/`);return{cols:Number(t),size:Number(n)}}return{cols:e}}function g(e,t,n){if(n==null||n===!1)return{};let{cols:r,size:i}=h(n),a=t.replace(e,``).toLowerCase();return e===`offset`?{className:`v-col--offset-${a}-${r}`,variables:[{[`--v-col-offset-base-${a}`]:i}]}:e===`order`?{className:`order-${a}-${r}`}:{className:r===``||r===!0?`v-col--${a}`:`v-col--cols-${a}-${r}`,variables:[{[`--v-col-size-base-${a}`]:i}]}}var _=[`auto`,`start`,`end`,`center`,`baseline`,`stretch`],v=e=>_.includes(e),y=s({cols:{type:[Boolean,String,Number],default:!1},...f,offset:{type:[String,Number],default:null},...p,order:{type:[String,Number],default:null},orderSm:{type:[String,Number],default:null},orderMd:{type:[String,Number],default:null},orderLg:{type:[String,Number],default:null},orderXl:{type:[String,Number],default:null},orderXxl:{type:[String,Number],default:null},alignSelf:{type:String,default:null,validator:v},...o(),...c()},`VCol`),b=a()({name:`VCol`,props:y(),setup(e,{slots:t}){let n=i(()=>h(e.cols).size),r=i(()=>h(e.offset).size),a=i(()=>{let t=[`v-col`],n=[],r;for(r in m)m[r].forEach(i=>{let a=e[i],{className:o,variables:s}=g(r,i,a);o&&t.push(o),s&&n.push(...s)});let{cols:i}=h(e.cols),{cols:a}=h(e.offset);return t.push({[`v-col--cols-${i}`]:i,[`v-col--offset-${a}`]:a,[`order-${e.order}`]:e.order,[`align-self-${e.alignSelf}`]:e.alignSelf}),{classes:t,variables:n}});return()=>l(e.tag,{class:[a.value.classes,e.class],style:[{"--v-col-size-base":n.value},{"--v-col-offset-base":r.value},a.value.variables,e.style]},t.default?.())}}),x=[`start`,`end`,`center`],S=[`space-between`,`space-around`,`space-evenly`],C=[...x,`baseline`,`stretch`],w=e=>C.includes(e),T=[...x,...S],E=e=>T.includes(e),D=[...x,...S,`stretch`],O=e=>D.includes(e),k={align:[`align`,`alignSm`,`alignMd`,`alignLg`,`alignXl`,`alignXxl`],justify:[`justify`,`justifySm`,`justifyMd`,`justifyLg`,`justifyXl`,`justifyXxl`],alignContent:[`alignContent`,`alignContentSm`,`alignContentMd`,`alignContentLg`,`alignContentXl`,`alignContentXxl`]},A={align:`align`,justify:`justify`,alignContent:`align-content`};function j(e,t,n){let r=A[e];if(n!=null){if(t){let n=t.replace(e,``);r+=`-${n}`}return r+=`-${n}`,r.toLowerCase()}}var M=s({dense:Boolean,align:{type:String,default:null,validator:w},alignSm:{type:String,default:null,validator:w},alignMd:{type:String,default:null,validator:w},alignLg:{type:String,default:null,validator:w},alignXl:{type:String,default:null,validator:w},alignXxl:{type:String,default:null,validator:w},justify:{type:String,default:null,validator:E},justifySm:{type:String,default:null,validator:E},justifyMd:{type:String,default:null,validator:E},justifyLg:{type:String,default:null,validator:E},justifyXl:{type:String,default:null,validator:E},justifyXxl:{type:String,default:null,validator:E},alignContent:{type:String,default:null,validator:O},alignContentSm:{type:String,default:null,validator:O},alignContentMd:{type:String,default:null,validator:O},alignContentLg:{type:String,default:null,validator:O},alignContentXl:{type:String,default:null,validator:O},alignContentXxl:{type:String,default:null,validator:O},noGutters:Boolean,gap:[Number,String,Array],size:[Number,String],...o(),...u(),...c()},`VRow`),N=a()({name:`VRow`,props:M(),setup(e,{slots:n}){e.dense&&t(`dense`,`density="comfortable"`);let r=i(()=>{let t=[],n;for(n in k)k[n].forEach(r=>{let i=e[r],a=j(n,r,i);a&&t.push(a)});return t.push({"v-row--no-gutters":e.noGutters,"v-row--density-default":e.density===`default`&&!e.noGutters&&!e.dense,"v-row--density-compact":e.density===`compact`,"v-row--density-comfortable":e.density===`comfortable`||e.dense,[`align-${e.align}`]:e.align,[`justify-${e.justify}`]:e.justify,[`align-content-${e.alignContent}`]:e.alignContent}),t}),a=i(()=>Array.isArray(e.gap)?d(e.gap[0]||0):d(e.gap)),o=i(()=>Array.isArray(e.gap)?d(e.gap[1]||0):a.value);return()=>l(e.tag,{class:[`v-row`,r.value,e.class],style:[{"--v-col-gap-x":a.value,"--v-col-gap-y":o.value,"--v-row-columns":e.size},e.style]},n.default?.())}});export{b as n,N as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{on as e}from"./index-o0-2k-vI.js";var t=e(`v-spacer`,`div`,`VSpacer`);export{t};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,$t as t,Ar as n,G as r,Nr as i,Rr as a,Tt as o,Un as s,X as c,Xn as l,Y as u,ar as d,br as f,cn as p,fn as m,kt as h,lr as g,nn as _,o as v,p as y,q as b,s as x,v as S,wn as C,zr as w}from"./index-o0-2k-vI.js";import{n as T,r as E}from"./VContainer-BbTGUkA2.js";var D=m({indeterminate:Boolean,inset:Boolean,flat:Boolean,loading:{type:[Boolean,String],default:!1},...x(),...E()},`VSwitch`),O=p()({name:`VSwitch`,inheritAttrs:!1,props:D(),emits:{"update:focused":e=>!0,"update:modelValue":e=>!0,"update:indeterminate":e=>!0},setup(p,{attrs:m,slots:x}){let E=t(p,`indeterminate`),D=t(p,`modelValue`),{loaderClasses:O}=b(p),{isFocused:k,focus:A,blur:j}=y(p),M=n(),N=n(),P=s&&window.matchMedia(`(forced-colors: active)`).matches,F=i(()=>typeof p.loading==`string`&&p.loading!==``?p.loading:p.color),I=f(),L=i(()=>p.id||`switch-${I}`);function R(){E.value&&=!1}function z(e){e.stopPropagation(),e.preventDefault(),M.value?.input?.click()}return _(()=>{let[t,n]=C(m),i=v.filterProps(p),s=T.filterProps(p);return d(v,g({ref:N,class:[`v-switch`,{"v-switch--flat":p.flat},{"v-switch--inset":p.inset},{"v-switch--indeterminate":E.value},O.value,p.class]},t,i,{modelValue:D.value,"onUpdate:modelValue":e=>D.value=e,id:L.value,focused:k.value,style:p.style}),{...x,default:({id:t,messagesId:i,isDisabled:f,isReadonly:m,isValid:_})=>{let v={model:D,isValid:_};return d(T,g({ref:M},s,{modelValue:D.value,"onUpdate:modelValue":[e=>D.value=e,R],id:t.value,"aria-describedby":i.value,type:`checkbox`,"aria-checked":E.value?`mixed`:void 0,disabled:f.value,readonly:m.value,onFocus:A,onBlur:j},n),{...x,default:({backgroundColorClasses:t,backgroundColorStyles:n})=>e(`div`,{class:a([`v-switch__track`,P?void 0:t.value]),style:w(n.value),onClick:z},[x[`track-true`]&&e(`div`,{key:`prepend`,class:`v-switch__track-true`},[x[`track-true`](v)]),x[`track-false`]&&e(`div`,{key:`append`,class:`v-switch__track-false`},[x[`track-false`](v)])]),input:({inputNode:t,icon:n,backgroundColorClasses:i,backgroundColorStyles:s})=>e(l,null,[t,e(`div`,{class:a([`v-switch__thumb`,{"v-switch__thumb--filled":n||p.loading},p.inset||P?void 0:i.value]),style:w(p.inset?void 0:s.value)},[x.thumb?d(o,{defaults:{VIcon:{icon:n,size:`x-small`}}},{default:()=>[x.thumb({...v,icon:n})]}):d(h,null,{default:()=>[p.loading?d(r,{name:`v-switch`,active:!0,color:_.value===!1?void 0:F.value},{default:e=>x.loader?x.loader(e):d(u,{active:e.isActive,color:e.color,indeterminate:!0,size:`16`,width:`2`},null)}):n&&d(c,{key:String(n),icon:n,size:`x-small`},null)]})])])})}})}),S({},N)}});export{O as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{Ft as e}from"./index-o0-2k-vI.js";function t(t,n){if(t instanceof e){let e=t.detail??t.message;return typeof e==`string`?e:Array.isArray(e)?e.map(e=>e?.msg||JSON.stringify(e)).join(`; `):e&&typeof e==`object`?JSON.stringify(e):t.message||n}return t instanceof Error&&t.message||n}export{t};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{n as e}from"./FilesPage-DQftGvc4.js";export{e as initMonaco};
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/tsMode-CkKdJi6q.js","assets/editor.api2-CLbMfXfG.js","assets/editor-DX2BVZF3.css","assets/workers-DM14Vrt9.js"])))=>i.map(i=>d[i]);
|
||||
import{g as e,h as t,n}from"./editor.api2-CLbMfXfG.js";import{Nt as r}from"./index-o0-2k-vI.js";var i=`5.9.3`,a=e({JsxEmit:()=>s,ModuleKind:()=>o,ModuleResolutionKind:()=>u,NewLineKind:()=>c,ScriptTarget:()=>l,getJavaScriptWorker:()=>_,getTypeScriptWorker:()=>g,javascriptDefaults:()=>h,typescriptDefaults:()=>m,typescriptVersion:()=>f}),o=(e=>(e[e.None=0]=`None`,e[e.CommonJS=1]=`CommonJS`,e[e.AMD=2]=`AMD`,e[e.UMD=3]=`UMD`,e[e.System=4]=`System`,e[e.ES2015=5]=`ES2015`,e[e.ESNext=99]=`ESNext`,e))(o||{}),s=(e=>(e[e.None=0]=`None`,e[e.Preserve=1]=`Preserve`,e[e.React=2]=`React`,e[e.ReactNative=3]=`ReactNative`,e[e.ReactJSX=4]=`ReactJSX`,e[e.ReactJSXDev=5]=`ReactJSXDev`,e))(s||{}),c=(e=>(e[e.CarriageReturnLineFeed=0]=`CarriageReturnLineFeed`,e[e.LineFeed=1]=`LineFeed`,e))(c||{}),l=(e=>(e[e.ES3=0]=`ES3`,e[e.ES5=1]=`ES5`,e[e.ES2015=2]=`ES2015`,e[e.ES2016=3]=`ES2016`,e[e.ES2017=4]=`ES2017`,e[e.ES2018=5]=`ES2018`,e[e.ES2019=6]=`ES2019`,e[e.ES2020=7]=`ES2020`,e[e.ESNext=99]=`ESNext`,e[e.JSON=100]=`JSON`,e[e.Latest=99]=`Latest`,e))(l||{}),u=(e=>(e[e.Classic=1]=`Classic`,e[e.NodeJs=2]=`NodeJs`,e))(u||{}),d=class{constructor(e,t,r,i,a){this._onDidChange=new n,this._onDidExtraLibsChange=new n,this._extraLibs=Object.create(null),this._removedExtraLibs=Object.create(null),this._eagerModelSync=!1,this.setCompilerOptions(e),this.setDiagnosticsOptions(t),this.setWorkerOptions(r),this.setInlayHintsOptions(i),this.setModeConfiguration(a),this._onDidExtraLibsChangeTimeout=-1}get onDidChange(){return this._onDidChange.event}get onDidExtraLibsChange(){return this._onDidExtraLibsChange.event}get modeConfiguration(){return this._modeConfiguration}get workerOptions(){return this._workerOptions}get inlayHintsOptions(){return this._inlayHintsOptions}getExtraLibs(){return this._extraLibs}addExtraLib(e,t){let n;if(n=t===void 0?`ts:extralib-${Math.random().toString(36).substring(2,15)}`:t,this._extraLibs[n]&&this._extraLibs[n].content===e)return{dispose:()=>{}};let r=1;return this._removedExtraLibs[n]&&(r=this._removedExtraLibs[n]+1),this._extraLibs[n]&&(r=this._extraLibs[n].version+1),this._extraLibs[n]={content:e,version:r},this._fireOnDidExtraLibsChangeSoon(),{dispose:()=>{let e=this._extraLibs[n];e&&e.version===r&&(delete this._extraLibs[n],this._removedExtraLibs[n]=r,this._fireOnDidExtraLibsChangeSoon())}}}setExtraLibs(e){for(let e in this._extraLibs)this._removedExtraLibs[e]=this._extraLibs[e].version;if(this._extraLibs=Object.create(null),e&&e.length>0)for(let t of e){let e=t.filePath||`ts:extralib-${Math.random().toString(36).substring(2,15)}`,n=t.content,r=1;this._removedExtraLibs[e]&&(r=this._removedExtraLibs[e]+1),this._extraLibs[e]={content:n,version:r}}this._fireOnDidExtraLibsChangeSoon()}_fireOnDidExtraLibsChangeSoon(){this._onDidExtraLibsChangeTimeout===-1&&(this._onDidExtraLibsChangeTimeout=window.setTimeout(()=>{this._onDidExtraLibsChangeTimeout=-1,this._onDidExtraLibsChange.fire(void 0)},0))}getCompilerOptions(){return this._compilerOptions}setCompilerOptions(e){this._compilerOptions=e||Object.create(null),this._onDidChange.fire(void 0)}getDiagnosticsOptions(){return this._diagnosticsOptions}setDiagnosticsOptions(e){this._diagnosticsOptions=e||Object.create(null),this._onDidChange.fire(void 0)}setWorkerOptions(e){this._workerOptions=e||Object.create(null),this._onDidChange.fire(void 0)}setInlayHintsOptions(e){this._inlayHintsOptions=e||Object.create(null),this._onDidChange.fire(void 0)}setMaximumWorkerIdleTime(e){}setEagerModelSync(e){this._eagerModelSync=e}getEagerModelSync(){return this._eagerModelSync}setModeConfiguration(e){this._modeConfiguration=e||Object.create(null),this._onDidChange.fire(void 0)}},f=i,p={completionItems:!0,hovers:!0,documentSymbols:!0,definitions:!0,references:!0,documentHighlights:!0,rename:!0,diagnostics:!0,documentRangeFormattingEdits:!0,signatureHelp:!0,onTypeFormattingEdits:!0,codeActions:!0,inlayHints:!0},m=new d({allowNonTsExtensions:!0,target:99},{noSemanticValidation:!1,noSyntaxValidation:!1,onlyVisible:!1},{},{},p),h=new d({allowNonTsExtensions:!0,allowJs:!0,target:99},{noSemanticValidation:!0,noSyntaxValidation:!1,onlyVisible:!1},{},{},p),g=()=>v().then(e=>e.getTypeScriptWorker()),_=()=>v().then(e=>e.getJavaScriptWorker());function v(){return r(()=>import(`./tsMode-CkKdJi6q.js`),__vite__mapDeps([0,1,2,3]))}t.onLanguage(`typescript`,()=>v().then(e=>e.setupTypeScript(m))),t.onLanguage(`javascript`,()=>v().then(e=>e.setupJavaScript(h)));export{m as n,a as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{Ar as e,Rt as t}from"./index-o0-2k-vI.js";import{o as n}from"./VContainer-BbTGUkA2.js";import{t as r}from"./apiError-nzQ5DKV3.js";function i(){let i=e([]),a=e(!1),o=n();async function s(e){a.value=!0;try{i.value=(await t.getList(`/servers/`,{per_page:e?.perPage??200})).items}catch(e){i.value=[],o(r(e,`加载服务器列表失败`),`error`)}finally{a.value=!1}}return{servers:i,loading:a,loadServers:s}}export{i as t};
|
||||
@@ -0,0 +1,2 @@
|
||||
import{Ar as e,Qn as t,Rr as n,Rt as r,er as i,hr as a,nr as o,or as s,tr as c,ur as l,yr as u}from"./index-o0-2k-vI.js";import{t as d}from"./_plugin-vue_export-helper-BDNMzG2s.js";var f=new Set(`if.then.else.elif.fi.for.while.do.done.case.esac.in.function.return.break.continue.select.until.cd.ls.grep.egrep.fgrep.find.cat.chmod.chown.docker.podman.kubectl.apt.apt-get.yum.dnf.apk.pip.pip3.npm.npx.ssh.scp.rsync.curl.wget.tar.gzip.gunzip.kill.ps.top.htop.nano.vim.vi.journalctl.systemctl.service.supervisorctl.crontab.nginx.df.du.free.mount.uname.hostname.whoami.id.pwd.head.tail.wc.sort.uniq.awk.sed.cut.tr.xargs.tee.netstat.ss.ip.ping.dig.git.make.nohup.timeout.watch`.split(`.`)),p=new Set([`echo`,`printf`,`test`,`read`,`pushd`,`popd`,`exit`,`set`,`unset`,`export`,`source`,`alias`,`type`,`command`,`hash`,`help`,`history`]);function m(e){return e===`sudo`?`sudo`:f.has(e)?`keyword`:p.has(e)?`builtin`:/^[/~]/.test(e)||e.startsWith(`./`)||e.startsWith(`../`)?`path`:/^\d+(?:\.\d+)?$/.test(e)?`number`:`text`}function h(e){let t=[],n=0;for(;n<e.length;){let r=e[n];if(r===`#`){let r=n+1;for(;r<e.length&&e[r]!==`
|
||||
`;)r++;t.push({type:`comment`,value:e.slice(n,r)}),n=r;continue}if(r===`'`||r===`"`){let i=r,a=n+1;for(;a<e.length;){if(e[a]===`\\`&&i===`"`){a+=2;continue}if(e[a]===i){a++;break}a++}t.push({type:`string`,value:e.slice(n,a)}),n=a;continue}if(r==="`"){let r=n+1;for(;r<e.length&&e[r]!=="`";)r++;r<e.length&&r++,t.push({type:`backtick`,value:e.slice(n,r)}),n=r;continue}if(r===`$`){let r=n+1;if(e[r]===`{`){for(;r<e.length&&e[r]!==`}`;)r++;r<e.length&&r++}else if(e[r]===`(`)r++;else for(;r<e.length&&/[\w]/.test(e[r]);)r++;t.push({type:`variable`,value:e.slice(n,r)}),n=r;continue}if(r===`|`||r===`&`||r===`;`){let r=n;e[r]===`|`&&e[r+1]===`|`||e[r]===`&`&&e[r+1]===`&`?r+=2:r+=1;let i=e.slice(n,r);t.push({type:i===`|`||i===`||`?`pipe`:`operator`,value:i}),n=r;continue}if(r===`>`||r===`<`){let r=n;e[r]===`>`&&e[r+1]===`>`||e[r]===`<`&&e[r+1]===`<`?r+=2:r+=1,e[r]===`&`&&r++,t.push({type:`redirect`,value:e.slice(n,r)}),n=r;continue}if(/\s/.test(r)){let r=n;for(;r<e.length&&/\s/.test(e[r]);)r++;t.push({type:`text`,value:e.slice(n,r)}),n=r;continue}if(r===`-`&&e[n+1]===`-`){let r=n+2;for(;r<e.length&&/[\w-]/.test(e[r]);)r++;t.push({type:`flag`,value:e.slice(n,r)}),n=r;continue}if(r===`-`&&/[a-zA-Z]/.test(e[n+1]||``)){let r=n+1;for(;r<e.length&&/[a-zA-Z]/.test(e[r]);)r++;t.push({type:`flag`,value:e.slice(n,r)}),n=r;continue}let i=n;for(;i<e.length&&!/[\s#"'`$|;&<>]/.test(e[i]);)i++;let a=e.slice(n,i);t.push({type:m(a),value:a}),n=i}return t}function g(e){return e.replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`)}var _={keyword:`sh-keyword`,builtin:`sh-builtin`,sudo:`sh-sudo`,pipe:`sh-pipe`,operator:`sh-operator`,redirect:`sh-redirect`,string:`sh-string`,flag:`sh-flag`,variable:`sh-variable`,backtick:`sh-backtick`,comment:`sh-comment`,number:`sh-number`,path:`sh-path`,text:`sh-text`};function v(e){return e?h(e).map(e=>`<span class="${_[e.type]}">${g(e.value)}</span>`).join(``):``}var y=[`innerHTML`],b=d(s({__name:`ShellHighlightField`,props:{modelValue:{},placeholder:{default:``},disabled:{type:Boolean,default:!1},multiline:{type:Boolean,default:!1},rows:{default:3},size:{default:`default`}},emits:[`update:modelValue`,`keydown`],setup(r,{expose:s,emit:d}){let f=e(null),p=r,m=d,h=t(()=>v(p.modelValue));function g(e){let t=e.target;m(`update:modelValue`,t.value)}function _(e){m(`keydown`,e)}function b(){return f.value}function x(){f.value?.focus()}function S(){let e=f.value;return e?e.value.substring(e.selectionStart??0,e.selectionEnd??0):``}function C(){let e=f.value;e&&(e.focus(),e.select())}async function w(e){let t=f.value;if(!t)return;let n=t.selectionStart??t.value.length,r=t.selectionEnd??n;m(`update:modelValue`,t.value.slice(0,n)+e+t.value.slice(r)),await l(),t.focus();let i=n+e.length;t.setSelectionRange(i,i)}return s({getInputEl:b,focusInput:x,getSelectionText:S,selectAllInput:C,insertAtSelection:w}),(e,t)=>(a(),o(`div`,{class:n([`sh-field position-relative`,{"sh-field--disabled":r.disabled,"sh-field--empty":!r.modelValue,"sh-field--large":r.size===`large`}])},[r.modelValue?(a(),o(`div`,{key:0,class:n([`sh-field__highlight font-mono`,r.multiline?`sh-field__highlight--multi`:`sh-field__highlight--single`]),"aria-hidden":`true`,innerHTML:h.value},null,10,y)):c(``,!0),(a(),i(u(r.multiline?`textarea`:`input`),{ref_key:`inputRef`,ref:f,value:r.modelValue,placeholder:r.placeholder,disabled:r.disabled,rows:r.multiline?r.rows:void 0,class:n([`sh-field__input font-mono`,r.multiline?`sh-field__input--multi`:`sh-field__input--single`]),spellcheck:`false`,autocomplete:`off`,onInput:g,onKeydown:_},null,40,[`value`,`placeholder`,`disabled`,`rows`,`class`]))],2))}}),[[`__scopeId`,`data-v-b73e3452`]]);function x(e){return[...e].sort((e,t)=>e.is_builtin===t.is_builtin?e.sort_order===t.sort_order?e.id-t.id:e.sort_order-t.sort_order:e.is_builtin?1:-1)}function S(){let t=e([]),n=e(!1);async function i(){n.value=!0;try{t.value=x(await r.get(`/terminal/quick-commands`)||[])}catch{throw t.value=[],Error(`加载快捷命令失败`)}finally{n.value=!1}}async function a(e,t){await r.post(`/terminal/quick-commands`,{name:e.trim(),cmd:t.trim()}),await i()}async function o(e,t,n){await r.put(`/terminal/quick-commands/${e}`,{name:t.trim(),cmd:n.trim()}),await i()}async function s(e){await r.delete(`/terminal/quick-commands/${e}`),await i()}async function c(e){t.value=x((await r.put(`/terminal/quick-commands/reorder`,{ids:e})).items||[])}return{commands:t,loading:n,load:i,create:a,update:o,remove:s,reorder:c,customCommands:()=>t.value.filter(e=>!e.is_builtin),builtinCommands:()=>t.value.filter(e=>e.is_builtin)}}export{S as n,b as r,x as t};
|
||||
+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-C_dP75wD.js"></script>
|
||||
<script type="module" crossorigin src="/app/assets/index-o0-2k-vI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BGzIPEZO.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user