глобал ссх
This commit is contained in:
parent
ee83f74fb9
commit
f77a5be371
8 changed files with 391 additions and 2 deletions
39
backend/alembic/versions/0012_global_credentials.py
Normal file
39
backend/alembic/versions/0012_global_credentials.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""global_ssh_credentials table
|
||||||
|
|
||||||
|
Revision ID: 0012
|
||||||
|
Revises: 0011
|
||||||
|
Create Date: 2026-04-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0012"
|
||||||
|
down_revision = "0011"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"global_ssh_credentials",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("type", sa.String(10), nullable=False),
|
||||||
|
sa.Column("username", sa.String(100), nullable=False),
|
||||||
|
sa.Column("password_enc", sa.String(500), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"ssh_key_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("description", sa.String(200), nullable=True),
|
||||||
|
sa.CheckConstraint("type IN ('password', 'key')", name="ck_global_ssh_cred_type"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_global_ssh_creds_priority", "global_ssh_credentials", ["priority"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_global_ssh_creds_priority", table_name="global_ssh_credentials")
|
||||||
|
op.drop_table("global_ssh_credentials")
|
||||||
|
|
@ -9,7 +9,7 @@ from .zone import Zone
|
||||||
from .snapshot import ConfigSnapshot, SnapshotContent
|
from .snapshot import ConfigSnapshot, SnapshotContent
|
||||||
from .alert import Alert
|
from .alert import Alert
|
||||||
from .plugin import Plugin
|
from .plugin import Plugin
|
||||||
from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential
|
from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential, GlobalSSHCredential
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
|
@ -32,4 +32,5 @@ __all__ = [
|
||||||
"SSHKey",
|
"SSHKey",
|
||||||
"ObjectSSHCredential",
|
"ObjectSSHCredential",
|
||||||
"ObjectCategoryCredential",
|
"ObjectCategoryCredential",
|
||||||
|
"GlobalSSHCredential",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -81,3 +81,29 @@ class ObjectCategoryCredential(Base):
|
||||||
|
|
||||||
object: Mapped["Object"] = relationship("Object", back_populates="category_credentials")
|
object: Mapped["Object"] = relationship("Object", back_populates="category_credentials")
|
||||||
ssh_key: Mapped[Optional["SSHKey"]] = relationship("SSHKey")
|
ssh_key: Mapped[Optional["SSHKey"]] = relationship("SSHKey")
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSSHCredential(Base):
|
||||||
|
"""App-wide SSH credential used as last-resort fallback for all objects/devices.
|
||||||
|
|
||||||
|
Tried after all object-level credentials are exhausted.
|
||||||
|
type='password': username + password_enc required.
|
||||||
|
type='key': username + ssh_key_id required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "global_ssh_credentials"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("type IN ('password', 'key')", name="ck_global_ssh_cred_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
type: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
username: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
password_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
ssh_key_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("ssh_keys.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
|
|
||||||
|
ssh_key: Mapped[Optional["SSHKey"]] = relationship("SSHKey")
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dependencies import get_db, require_admin
|
from app.dependencies import get_db, require_admin
|
||||||
from app.models.plugin import Plugin
|
from app.models.plugin import Plugin
|
||||||
|
from app.models.ssh_key import GlobalSSHCredential
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.plugins.loader import BUILTIN_ACTIONS
|
from app.plugins.loader import BUILTIN_ACTIONS
|
||||||
from app.plugins.registry import action_registry
|
from app.plugins.registry import action_registry
|
||||||
from app.schemas.admin import PluginRead, PluginUpdate, UserCreate, UserRead, UserUpdate
|
from app.schemas.admin import PluginRead, PluginUpdate, UserCreate, UserRead, UserUpdate
|
||||||
|
from app.schemas.ssh_key import GlobalSSHCredentialCreate, GlobalSSHCredentialRead
|
||||||
from app.services.auth import hash_password
|
from app.services.auth import hash_password
|
||||||
|
from app.services.crypto import encrypt
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
@ -158,3 +161,48 @@ async def delete_user(
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
await db.delete(user)
|
await db.delete(user)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Global SSH Credentials ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/global-ssh-credentials", response_model=list[GlobalSSHCredentialRead])
|
||||||
|
async def list_global_ssh_credentials(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(GlobalSSHCredential).order_by(GlobalSSHCredential.priority, GlobalSSHCredential.id)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/global-ssh-credentials", response_model=GlobalSSHCredentialRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_global_ssh_credential(
|
||||||
|
body: GlobalSSHCredentialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cred = GlobalSSHCredential(
|
||||||
|
type=body.type,
|
||||||
|
username=body.username,
|
||||||
|
password_enc=encrypt(body.password) if body.password else None,
|
||||||
|
ssh_key_id=body.ssh_key_id,
|
||||||
|
priority=body.priority,
|
||||||
|
description=body.description,
|
||||||
|
)
|
||||||
|
db.add(cred)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(cred)
|
||||||
|
return cred
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/global-ssh-credentials/{cred_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_global_ssh_credential(
|
||||||
|
cred_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cred = await db.get(GlobalSSHCredential, cred_id)
|
||||||
|
if cred is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
|
||||||
|
await db.delete(cred)
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,33 @@ class ObjectCategoryCredentialRead(BaseModel):
|
||||||
priority: int
|
priority: int
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Global SSH credentials ---
|
||||||
|
|
||||||
|
class GlobalSSHCredentialCreate(BaseModel):
|
||||||
|
type: Literal["password", "key"]
|
||||||
|
username: str
|
||||||
|
password: Optional[str] = None
|
||||||
|
ssh_key_id: Optional[int] = None
|
||||||
|
priority: int = 0
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def check_fields(self) -> "GlobalSSHCredentialCreate":
|
||||||
|
if self.type == "password" and not self.password:
|
||||||
|
raise ValueError("password is required for type=password")
|
||||||
|
if self.type == "key" and self.ssh_key_id is None:
|
||||||
|
raise ValueError("ssh_key_id is required for type=key")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSSHCredentialRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
type: str
|
||||||
|
username: str
|
||||||
|
ssh_key_id: Optional[int] = None
|
||||||
|
priority: int
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ class SSHResult:
|
||||||
exit_code: int = -1
|
exit_code: int = -1
|
||||||
duration_ms: int = 0
|
duration_ms: int = 0
|
||||||
error: str = ""
|
error: str = ""
|
||||||
|
# True when SSH auth succeeded (connection established), even if command returned non-zero.
|
||||||
|
# Used to distinguish "wrong password" from "command failed".
|
||||||
|
auth_ok: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -91,6 +94,35 @@ async def _load_key_path(db: AsyncSession, key_id: int) -> Optional[str]:
|
||||||
return key.path if key else None
|
return key.path if key else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_global_credentials(
|
||||||
|
options: list[_AuthOption],
|
||||||
|
seen: set[str],
|
||||||
|
db: Optional[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
"""Append global SSH credentials (app-wide fallback) to the options list."""
|
||||||
|
if not db:
|
||||||
|
return
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
from app.models.ssh_key import GlobalSSHCredential
|
||||||
|
|
||||||
|
def _add(opt: _AuthOption) -> None:
|
||||||
|
k = _opt_key(opt)
|
||||||
|
if k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
options.append(opt)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
sa_select(GlobalSSHCredential).order_by(GlobalSSHCredential.priority, GlobalSSHCredential.id)
|
||||||
|
)
|
||||||
|
for gcred in result.scalars().all():
|
||||||
|
if gcred.type == "key" and gcred.ssh_key_id:
|
||||||
|
path = await _load_key_path(db, gcred.ssh_key_id)
|
||||||
|
if path:
|
||||||
|
_add(_AuthOption(type="key", username=gcred.username, key_path=path, key_id=gcred.ssh_key_id))
|
||||||
|
elif gcred.type == "password" and gcred.password_enc:
|
||||||
|
_add(_AuthOption(type="password", username=gcred.username, password=decrypt(gcred.password_enc)))
|
||||||
|
|
||||||
|
|
||||||
async def build_auth_options(device: Device, obj: Object, db: Optional[AsyncSession] = None) -> list[_AuthOption]:
|
async def build_auth_options(device: Device, obj: Object, db: Optional[AsyncSession] = None) -> list[_AuthOption]:
|
||||||
"""Build an ordered list of auth options to try for this device.
|
"""Build an ordered list of auth options to try for this device.
|
||||||
|
|
||||||
|
|
@ -189,6 +221,9 @@ async def build_auth_options(device: Device, obj: Object, db: Optional[AsyncSess
|
||||||
password=decrypt(obj.ssh_pass_enc),
|
password=decrypt(obj.ssh_pass_enc),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# 7. Global app-wide credentials (last resort)
|
||||||
|
await _add_global_credentials(options, seen, db)
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -225,6 +260,7 @@ async def _try_connect(
|
||||||
stderr=(result.stderr or "").strip(),
|
stderr=(result.stderr or "").strip(),
|
||||||
exit_code=result.exit_status or 0,
|
exit_code=result.exit_status or 0,
|
||||||
duration_ms=duration,
|
duration_ms=duration,
|
||||||
|
auth_ok=True, # Connection was established — auth succeeded
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return SSHResult(
|
return SSHResult(
|
||||||
|
|
@ -317,11 +353,14 @@ async def run_command(
|
||||||
for i, opt in enumerate(options):
|
for i, opt in enumerate(options):
|
||||||
result = await _try_connect(device.ip, port, opt, command, timeout)
|
result = await _try_connect(device.ip, port, opt, command, timeout)
|
||||||
|
|
||||||
if result.success:
|
if result.auth_ok:
|
||||||
|
# Connection was established — auth succeeded.
|
||||||
|
# Return the result regardless of exit_code (command may have failed, not auth).
|
||||||
if db:
|
if db:
|
||||||
await _save_last_auth(device, db, opt)
|
await _save_last_auth(device, db, opt)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Auth failed (wrong password / key / connection error)
|
||||||
# If last_auth method failed, clear it
|
# If last_auth method failed, clear it
|
||||||
if i == 0 and opt is last_auth_option and db:
|
if i == 0 and opt is last_auth_option and db:
|
||||||
device.ssh_last_auth = None
|
device.ssh_last_auth = None
|
||||||
|
|
@ -414,4 +453,7 @@ async def build_object_auth_options(obj: Object, db: Optional[AsyncSession] = No
|
||||||
if obj.ssh_user and obj.ssh_pass_enc:
|
if obj.ssh_user and obj.ssh_pass_enc:
|
||||||
_add(_AuthOption(type="password", username=obj.ssh_user, password=decrypt(obj.ssh_pass_enc)))
|
_add(_AuthOption(type="password", username=obj.ssh_user, password=decrypt(obj.ssh_pass_enc)))
|
||||||
|
|
||||||
|
# Global app-wide credentials (last resort)
|
||||||
|
await _add_global_credentials(options, seen, db)
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,44 @@ export const useDeleteAdminUser = () => {
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-users'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-users'] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Global SSH Credentials ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GlobalSSHCredentialItem {
|
||||||
|
id: number
|
||||||
|
type: 'password' | 'key'
|
||||||
|
username: string
|
||||||
|
ssh_key_id: number | null
|
||||||
|
priority: number
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGlobalSSHCredentials = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['global-ssh-credentials'],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<GlobalSSHCredentialItem[]>('/api/v1/admin/global-ssh-credentials').then((r) => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useCreateGlobalSSHCredential = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: {
|
||||||
|
type: 'password' | 'key'
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
ssh_key_id?: number
|
||||||
|
priority?: number
|
||||||
|
description?: string
|
||||||
|
}) => api.post<GlobalSSHCredentialItem>('/api/v1/admin/global-ssh-credentials', body).then((r) => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['global-ssh-credentials'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteGlobalSSHCredential = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/api/v1/admin/global-ssh-credentials/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['global-ssh-credentials'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
|
@ -14,6 +15,7 @@ import {
|
||||||
Divider,
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
|
|
@ -31,9 +33,13 @@ import {
|
||||||
useAdminUsers,
|
useAdminUsers,
|
||||||
useCreateAdminUser,
|
useCreateAdminUser,
|
||||||
useDeleteAdminUser,
|
useDeleteAdminUser,
|
||||||
|
useGlobalSSHCredentials,
|
||||||
|
useCreateGlobalSSHCredential,
|
||||||
|
useDeleteGlobalSSHCredential,
|
||||||
usePlugins,
|
usePlugins,
|
||||||
useUpdateAdminUser,
|
useUpdateAdminUser,
|
||||||
useUpdatePlugin,
|
useUpdatePlugin,
|
||||||
|
type GlobalSSHCredentialItem,
|
||||||
type UserItem,
|
type UserItem,
|
||||||
} from '../api/admin'
|
} from '../api/admin'
|
||||||
import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials'
|
import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials'
|
||||||
|
|
@ -373,6 +379,153 @@ function SSHKeysTab() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Global SSH Credentials Tab ────────────────────────────────────────────────
|
||||||
|
function GlobalCredsTab() {
|
||||||
|
const { data: creds, isLoading } = useGlobalSSHCredentials()
|
||||||
|
const { data: sshKeys } = useSSHKeys()
|
||||||
|
const createCred = useCreateGlobalSSHCredential()
|
||||||
|
const deleteCred = useDeleteGlobalSSHCredential()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
const payload = stripEmpty(values)
|
||||||
|
try {
|
||||||
|
await createCred.mutateAsync(payload as any)
|
||||||
|
message.success('Учётные данные добавлены')
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.detail ?? 'Ошибка при создании')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Тип',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
width: 90,
|
||||||
|
render: (v: string) => <Tag color={v === 'key' ? 'green' : 'blue'}>{v === 'key' ? 'SSH-ключ' : 'Пароль'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Логин',
|
||||||
|
dataIndex: 'username',
|
||||||
|
key: 'username',
|
||||||
|
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SSH-ключ',
|
||||||
|
dataIndex: 'ssh_key_id',
|
||||||
|
key: 'ssh_key_id',
|
||||||
|
render: (v: number | null) => {
|
||||||
|
if (!v) return '—'
|
||||||
|
const key = (sshKeys ?? []).find((k) => k.id === v)
|
||||||
|
return key ? <Tag color="green">{key.name}</Tag> : <Tag>key_id={v}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Приоритет',
|
||||||
|
dataIndex: 'priority',
|
||||||
|
key: 'priority',
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Описание',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
render: (v: string | null) => v ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 60,
|
||||||
|
render: (_: unknown, row: GlobalSSHCredentialItem) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="Удалить глобальные учётные данные?"
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => deleteCred.mutateAsync(row.id).catch(() => message.error('Ошибка при удалении'))}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
Глобальные SSH-кредентиалы используются как последний резерв при подключении к устройствам,
|
||||||
|
если ни объектные, ни device-level настройки не подошли.
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ marginBottom: 16, textAlign: 'right' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
dataSource={creds ?? []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
size="middle"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="Добавить глобальные SSH-кредентиалы"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={handleCreate}
|
||||||
|
confirmLoading={createCred.isPending}
|
||||||
|
okText="Добавить"
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="type" label="Тип" rules={[{ required: true }]} initialValue="password">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'password', label: 'Пароль' },
|
||||||
|
{ value: 'key', label: 'SSH-ключ' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="priority" label="Приоритет" initialValue={0}>
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="username" label="Логин" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="root" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.type !== cur.type}>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('type') === 'key' ? (
|
||||||
|
<Form.Item name="ssh_key_id" label="SSH-ключ" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
placeholder="Выбрать ключ"
|
||||||
|
options={(sshKeys ?? []).map((k) => ({ value: k.id, label: `${k.name} (${k.path})` }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="Описание">
|
||||||
|
<Input placeholder="Дефолтный пароль устройств" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -412,6 +565,15 @@ export function AdminPage() {
|
||||||
),
|
),
|
||||||
children: <SSHKeysTab />,
|
children: <SSHKeysTab />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'global-creds',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<GlobalOutlined /> Глобальные SSH-кредентиалы
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: <GlobalCredsTab />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue