Files
Nexus/server/api/schemas.py
T
Nexus Agent 1c0d7e9eb6 fix(api): localize service-layer ValueError messages to Chinese
So str(exc) passthrough on scripts, batch, sync, and schedule routes
returns Chinese without relying only on the HTTP detail translation layer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:53:18 +08:00

794 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Nexus — Pydantic Request/Response Models
Shared schemas for API validation, replacing raw `dict` parameters.
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, field_validator, model_validator
from server.api.schema_path_validators import (
coerce_nexus_host_abs_path,
coerce_optional_iso_datetime,
coerce_optional_remote_abs_path,
coerce_remote_abs_path,
coerce_remote_abs_path_list,
coerce_remote_relative_path,
)
# ── Setting ──
# 与 SyncFiles / ScriptExecute 一致:批量选机上限
SERVER_IDS_BATCH_MAX = 2000
class SettingUpdatePayload(BaseModel):
value: str = Field(..., min_length=0, description="设置值(字符串存储)")
@field_validator("value", mode="before")
@classmethod
def _coerce_value_to_str(cls, v: object) -> object:
if v is None:
return ""
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(int(v)) if isinstance(v, float) and v == int(v) else str(v)
return v
class ApiKeyRevealRequest(BaseModel):
"""Shared re-auth payload for revealing sensitive values (API key, install cmd, etc.)."""
current_password: str = Field(..., min_length=1, max_length=255)
# ── Agent ──
class AgentHeartbeat(BaseModel):
server_id: Optional[int] = None # None = unregistered agent, silently discard
is_online: bool = True
system_info: Optional[dict] = None
agent_version: Optional[str] = None
# ── Server ──
class ServerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
domain: str = Field(..., min_length=1, max_length=255)
port: int = Field(22, ge=1, le=65535)
username: str = Field("root", max_length=100)
auth_method: str = Field("key", pattern="^(key|password)$")
password: Optional[str] = None
preset_id: Optional[int] = None
ssh_key_path: Optional[str] = None
ssh_key_private: Optional[str] = None
ssh_key_public: Optional[str] = None
ssh_key_preset_id: Optional[int] = None
agent_port: int = Field(8601, ge=1, le=65535)
description: Optional[str] = None
target_path: Optional[str] = None
category: Optional[str] = None
platform_id: Optional[int] = None
node_id: Optional[int] = None
protocols: Optional[list] = None
extra_attrs: Optional[dict] = None
files_elevation: Optional[str] = Field(
default=None,
pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)",
)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class ServerUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
domain: Optional[str] = Field(None, min_length=1, max_length=255)
port: Optional[int] = Field(None, ge=1, le=65535)
username: Optional[str] = None
auth_method: Optional[str] = Field(None, pattern="^(key|password)$")
password: Optional[str] = None
preset_id: Optional[int] = None
ssh_key_path: Optional[str] = None
ssh_key_private: Optional[str] = None
ssh_key_public: Optional[str] = None
ssh_key_preset_id: Optional[int] = None
agent_port: Optional[int] = Field(None, ge=1, le=65535)
agent_api_key: Optional[str] = None
description: Optional[str] = None
target_path: Optional[str] = None
category: Optional[str] = None
platform_id: Optional[int] = None
node_id: Optional[int] = None
protocols: Optional[list] = None
extra_attrs: Optional[dict] = None
files_elevation: Optional[str] = Field(
default=None,
pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)",
)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
if v is None:
return None
return coerce_optional_remote_abs_path(str(v))
class ServerCheck(BaseModel):
server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
class ServerImportResult(BaseModel):
"""Result of CSV batch import."""
total: int = 0
created: int = 0
skipped: int = 0
failed: int = 0
errors: List[dict] = [] # [{row, name, domain, error}]
created_ids: List[int] = []
class BatchAgentAction(BaseModel):
"""Batch install/upgrade Agent on multiple servers."""
server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
class BatchAgentResultItem(BaseModel):
server_id: int
server_name: str
success: bool
stdout: str = ""
error: str = ""
class BatchAgentResult(BaseModel):
results: List[BatchAgentResultItem] = []
class BatchCategoryUpdate(BaseModel):
"""Batch update server category for selected IDs."""
server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
category: str = Field("", max_length=100, description="分类名称;留空表示清除分类")
class BatchCategoryResult(BaseModel):
updated: int = 0
not_found: List[int] = []
class BatchJobStarted(BaseModel):
"""Immediate response when a server batch job is queued."""
job_id: int
status: str = "running"
label: str
op: str
total: int = 0
done: int = 0
remaining: int = 0
success: int = 0
failed: int = 0
progress: str = "0/0"
class BatchJobStatus(BaseModel):
job_id: int
op: str
label: str
status: str
total: int = 0
done: int = 0
remaining: int = 0
success: int = 0
failed: int = 0
progress: str = "0/0"
operator: str = ""
results: List[BatchAgentResultItem] = []
class FileUpload(BaseModel):
"""Upload file to remote server via SFTP."""
server_id: int = Field(..., ge=1)
remote_path: str = Field(..., min_length=1)
@field_validator("remote_path", mode="before")
@classmethod
def _normalize_remote_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
# ── Sync ──
class SyncFiles(BaseModel):
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
batch_size: int = Field(50, ge=1, le=200)
concurrency: int = Field(10, ge=1, le=50)
batch_id: Optional[str] = Field(
None,
min_length=12,
max_length=12,
description="Client-supplied push batch id (12 hex); must connect /ws/sync before POST",
)
@field_validator("batch_id", mode="before")
@classmethod
def _normalize_batch_id(cls, v: object) -> object:
if v is None or v == "":
return None
s = str(v).strip().lower()
if len(s) != 12 or not all(c in "0123456789abcdef" for c in s):
raise ValueError("batch_id 必须为 12 位十六进制字符")
return s
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class SyncPreview(BaseModel):
"""Dry-run: rsync --dry-run --stats against one representative server."""
server_id: int = Field(..., ge=1)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
verbose: bool = False # If True, include per-file list (truncated to 200 lines)
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class SyncBrowse(BaseModel):
server_id: int
path: str = Field("/", min_length=1)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class SyncBrowseLocal(BaseModel):
"""Browse a local directory on the Nexus server (for upload file manager)."""
path: str = Field(..., min_length=1)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
class LocalFileOperation(BaseModel):
"""Delete or rename a file/directory in the local upload staging area."""
operation: str = Field(..., pattern="^(delete|rename)$")
path: str = Field(..., min_length=1)
new_name: Optional[str] = None # required for rename
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
class LocalFilePreview(BaseModel):
"""Preview file content in the local upload staging area."""
path: str = Field(..., min_length=1)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
class ValidateSourcePath(BaseModel):
"""Validate a local directory path on the Nexus server as a push source."""
path: str = Field(..., min_length=1)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
class SyncDiagnose(BaseModel):
"""Diagnose why a push failed for a specific server."""
server_id: int = Field(..., ge=1)
target_path: Optional[str] = None
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class FileSyncDiff(BaseModel):
"""Compare a local file with its remote counterpart to show push diff."""
server_id: int = Field(..., ge=1)
source_path: str = Field(..., min_length=1)
relative_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
@field_validator("relative_path", mode="before")
@classmethod
def _normalize_relative_path(cls, v: object) -> str:
return coerce_remote_relative_path(str(v))
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class SyncCancel(BaseModel):
"""Cancel an in-progress push by batch_id."""
batch_id: str = Field(..., min_length=1, max_length=32)
class ReconcileStaleLogs(BaseModel):
"""Mark long-stuck sync_logs rows (status=running) as failed."""
max_age_minutes: int = Field(120, ge=5, le=10080)
class SyncVerify(BaseModel):
"""Post-push verification: compare local source with remote target via md5sum."""
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
max_files: int = Field(200, ge=1, le=500)
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> str:
return coerce_nexus_host_abs_path(str(v))
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class FileOperation(BaseModel):
"""Remote file operation via SSH exec (delete / rename / mkdir)."""
server_id: int = Field(..., ge=1)
operation: str = Field(..., pattern="^(delete|rename|mkdir)$")
path: str = Field(..., min_length=1) # source path (or new dir path for mkdir)
new_path: Optional[str] = None # required for rename
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
@field_validator("new_path", mode="before")
@classmethod
def _normalize_new_path(cls, v: object) -> object:
if v is None:
return None
return coerce_remote_abs_path(str(v))
class FileClipboardApply(BaseModel):
"""Batch copy or move (cut+paste) remote paths into a destination directory."""
server_id: int = Field(..., ge=1)
mode: str = Field(..., pattern="^(copy|move)$")
sources: list[str] = Field(..., min_length=1, max_length=50)
dest_dir: str = Field(..., min_length=1, max_length=4096)
@field_validator("sources", mode="before")
@classmethod
def _normalize_sources(cls, v: object) -> list[str]:
if not isinstance(v, list):
raise ValueError("sources 必须为路径列表")
return coerce_remote_abs_path_list([str(x) for x in v])
@field_validator("dest_dir", mode="before")
@classmethod
def _normalize_dest_dir(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileDownload(BaseModel):
"""Download a file from a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileRead(BaseModel):
"""Read text file content from a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
max_size: int = Field(1_000_000, ge=1, le=5_000_000) # 1MB default, 5MB max
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileWrite(BaseModel):
"""Write text content to a file on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
content: str = Field(..., max_length=5_000_000)
etag: str | None = Field(None, max_length=64)
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileChmod(BaseModel):
"""Change file permissions on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
mode: str = Field(..., min_length=3, max_length=4, pattern=r"^[0-7]{3,4}$")
owner: Optional[str] = Field(None, max_length=64, pattern=r"^[a-zA-Z0-9_.-]+$")
group: Optional[str] = Field(None, max_length=64, pattern=r"^[a-zA-Z0-9_.-]+$")
recursive: bool = False
@field_validator("path", mode="before")
@classmethod
def _normalize_path(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
@model_validator(mode="after")
def _group_requires_owner(self):
if self.group and not self.owner:
raise ValueError("指定属组时必须同时指定属主")
return self
class FileCompress(BaseModel):
"""Compress files into an archive on a remote server."""
server_id: int = Field(..., ge=1)
paths: list[str] = Field(..., min_length=1, max_length=100)
dest: str = Field(..., min_length=1, max_length=4096)
format: str = Field("tar.gz", pattern=r"^(tar\.gz|zip)$")
@field_validator("paths", mode="before")
@classmethod
def _normalize_paths(cls, v: object) -> list[str]:
if not isinstance(v, list):
raise ValueError("paths 必须为路径列表")
return coerce_remote_abs_path_list([str(x) for x in v])
@field_validator("dest", mode="before")
@classmethod
def _normalize_dest(cls, v: object) -> str:
return coerce_remote_abs_path(str(v))
class FileDecompress(BaseModel):
"""Decompress an archive on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
dest: str = Field(..., min_length=1, max_length=4096)
@field_validator("path", "dest", mode="before")
@classmethod
def _normalize_paths(cls, v: object) -> str:
text = str(v).strip()
if text == ".":
raise ValueError("解压目标必须为绝对路径(不可使用 '.'")
return coerce_remote_abs_path(text)
# ── Script ──
class ScriptCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
category: str = Field("ops", pattern="^(ops|deploy|check|cleanup)$")
content: str = Field(..., min_length=1)
description: Optional[str] = None
created_by: Optional[str] = None
class ScriptUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = Field(None, pattern="^(ops|deploy|check|cleanup)$")
content: Optional[str] = None
description: Optional[str] = None
class ScriptExecute(BaseModel):
script_id: Optional[int] = Field(None, ge=1)
command: Optional[str] = Field(None, min_length=1)
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
credential_id: Optional[int] = None
timeout: int = Field(60, ge=1, le=600)
long_task: bool = False
@model_validator(mode="after")
def require_command_or_script(self) -> "ScriptExecute":
cmd = (self.command or "").strip()
if cmd:
self.command = cmd
elif not self.script_id:
raise ValueError("须提供 command 或 script_id")
return self
class ScriptExecutionRetry(BaseModel):
server_ids: Optional[List[int]] = None
credential_id: Optional[int] = None
long_task: Optional[bool] = None
timeout: int = Field(120, ge=1, le=600)
class ScriptExecutionMarkStuck(BaseModel):
detail: str = Field("", max_length=500)
class AgentScriptCallback(BaseModel):
"""Child host reports long-running script completion (curl from nohup wrapper)."""
job_id: str = Field(..., min_length=8, max_length=64)
server_id: int = Field(..., ge=1)
secret: str = Field(..., min_length=16, max_length=128)
exit_code: int = 0
message: str = Field("", max_length=500)
log_tail: Optional[str] = Field(None, max_length=2000)
# ── DB Credential ──
class DbCredentialCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
db_type: str = Field("mysql", pattern="^(mysql|postgresql|mariadb|mongodb|redis)$")
host: str = Field(..., min_length=1)
port: int = Field(3306, ge=1, le=65535)
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
database: Optional[str] = None
class DbCredentialUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
db_type: Optional[str] = Field(None, pattern="^(mysql|postgresql|mariadb|mongodb|redis)$")
host: Optional[str] = Field(None, min_length=1)
port: Optional[int] = Field(None, ge=1, le=65535)
username: Optional[str] = Field(None, min_length=1)
password: Optional[str] = Field(None, min_length=1, description="留空则不修改密码")
database: Optional[str] = None
# ── Schedule ──
class ScheduleCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
server_ids: str = Field(..., min_length=3) # JSON array string
run_mode: str = Field("once", pattern="^(once|cron)$")
cron_expr: Optional[str] = None # required when run_mode=cron
fire_at: Optional[datetime] = None # required when run_mode=once
enabled: bool = True
# Schedule type
schedule_type: str = Field("push", pattern="^(push|script)$")
# Push-specific (required when schedule_type == 'push')
source_path: Optional[str] = None
target_path: Optional[str] = None
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
# Script-specific (required when schedule_type == 'script')
script_id: Optional[int] = None
script_content: Optional[str] = None
exec_timeout: int = Field(60, ge=10, le=600)
long_task: bool = False
@model_validator(mode="after")
def _validate_schedule_fields(self):
if self.run_mode == "cron" and not self.cron_expr:
raise ValueError("cron 模式必须提供 cron_expr")
if self.run_mode == "once" and not self.fire_at:
raise ValueError("一次性模式必须提供 fire_at 执行时间")
if self.schedule_type == "script" and not self.script_id and not self.script_content:
raise ValueError("script 类型调度必须提供 script_id 或 script_content")
if self.schedule_type == "push" and not self.source_path:
raise ValueError("push 类型调度必须提供 source_path")
return self
@field_validator("fire_at", mode="before")
@classmethod
def _parse_fire_at(cls, v: object) -> object:
return coerce_optional_iso_datetime(v)
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> object:
if v is None:
return None
text = str(v).strip()
if not text:
return None
return coerce_nexus_host_abs_path(text)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class ScheduleUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
source_path: Optional[str] = None
target_path: Optional[str] = None
server_ids: Optional[str] = None
run_mode: Optional[str] = Field(None, pattern="^(once|cron)$")
cron_expr: Optional[str] = None
fire_at: Optional[datetime] = None
sync_mode: Optional[str] = Field(None, pattern="^(incremental|full|overwrite|checksum)$")
enabled: Optional[bool] = None
schedule_type: Optional[str] = Field(None, pattern="^(push|script)$")
script_id: Optional[int] = None
script_content: Optional[str] = None
exec_timeout: Optional[int] = Field(None, ge=10, le=600)
long_task: Optional[bool] = None
@field_validator("fire_at", mode="before")
@classmethod
def _parse_fire_at(cls, v: object) -> object:
return coerce_optional_iso_datetime(v)
@field_validator("source_path", mode="before")
@classmethod
def _normalize_source_path(cls, v: object) -> object:
if v is None:
return None
text = str(v).strip()
if not text:
return None
return coerce_nexus_host_abs_path(text)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
# ── Preset ──
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="留空则不修改密码")
# ── SSH Key Preset ──
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))
class AddByIpsBatchRequest(BaseModel):
"""批量按 IP 添加:每行一个地址(可选 ``名称<TAB>IP``),凭据预设轮询 SSH,失败入 pending。"""
text: str = Field(..., min_length=1, max_length=50_000, description="每行一个 IP/域名,或 名称<TAB>IP")
port: int = Field(22, ge=1, le=65535)
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_batch(cls, v: object) -> object:
return coerce_optional_remote_abs_path(v if v is None else str(v))
class CredentialPollErrorOut(BaseModel):
preset_type: str
preset_name: str
username: str
error: str
class AddByIpsBatchItemResult(BaseModel):
domain: str
status: str = Field(..., description="created | pending | skipped")
server_id: Optional[int] = None
pending_id: Optional[int] = None
message: Optional[str] = None
matched_preset: Optional[str] = None
matched_username: Optional[str] = None
errors: Optional[List[CredentialPollErrorOut]] = None
class AddByIpsBatchResult(BaseModel):
total: int = 0
created: int = 0
pending: int = 0
skipped: int = 0
input_lines: int = Field(0, description="非空输入行数(去重前)")
duplicates_removed: int = Field(0, description="输入中重复行已忽略的数量")
items: List[AddByIpsBatchItemResult] = []
# ── Platform / Node ──
class PlatformCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
category: str = Field(..., min_length=1, max_length=50)
type: str = Field(..., min_length=1, max_length=50)
default_protocols: Optional[list] = None
charset: str = "utf-8"
class PlatformUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = None
type: Optional[str] = None
default_protocols: Optional[list] = None
charset: Optional[str] = None
class NodeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
parent_id: Optional[int] = None
sort_order: int = 0
class NodeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
parent_id: Optional[int] = None
sort_order: Optional[int] = None