feat(servers): 凭据轮询登录与连接失败列表

- 密码/SSH密钥预设增加用户名;快速添加(IP)轮询全部预设尝试 SSH 登录
- 成功入 servers 列表;失败写入 pending_servers 并支持重试/删除
- 新增 credential_poller、try_ssh_login 与 add-by-ip/pending API
This commit is contained in:
Nexus Deploy
2026-06-06 20:08:48 +08:00
parent 5a56e5a8cb
commit 4ba45abf38
51 changed files with 1372 additions and 21 deletions
@@ -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')
+23 -11
View File
@@ -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 }
}
+272
View File
@@ -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>
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+25
View File
@@ -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"""
+233
View File
@@ -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"
+1
View File
@@ -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};
+1
View File
@@ -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};
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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};
+1
View File
@@ -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};
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
import{on as e}from"./index-o0-2k-vI.js";var t=e(`v-spacer`,`div`,`VSpacer`);export{t};
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`)}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
View File
@@ -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>