фикс задач категорий

This commit is contained in:
dv 2026-04-10 12:25:30 +03:00
parent af1a14a6a5
commit 3c3624d848
8 changed files with 156 additions and 36 deletions

View file

@ -0,0 +1,31 @@
"""0014 plugin compatible_categories
Revision ID: 0014
Revises: 0013
Create Date: 2026-04-10
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"plugins",
sa.Column(
"compatible_categories",
postgresql.ARRAY(sa.String()),
nullable=False,
server_default="{}",
),
)
def downgrade() -> None:
op.drop_column("plugins", "compatible_categories")

View file

@ -2,6 +2,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Boolean, DateTime, String, Text from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.dialects.postgresql import ARRAY as PgARRAY
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@ -17,3 +18,6 @@ class Plugin(Base):
description: Mapped[Optional[str]] = mapped_column(Text(), nullable=True) description: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean(), nullable=False, default=True) enabled: Mapped[bool] = mapped_column(Boolean(), nullable=False, default=True)
registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
compatible_categories: Mapped[list[str]] = mapped_column(
PgARRAY(String()), nullable=False, default=list, server_default="{}"
)

View file

@ -75,6 +75,7 @@ BUILTIN_ACTIONS: dict[str, BaseAction] = {
async def load_plugins() -> None: async def load_plugins() -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Insert new plugins with code defaults; preserve existing DB rows (including user edits)
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
async with db.begin(): async with db.begin():
for action in BUILTIN_ACTIONS.values(): for action in BUILTIN_ACTIONS.values():
@ -86,19 +87,28 @@ async def load_plugins() -> None:
description=(action.__class__.__doc__ or "").strip() or None, description=(action.__class__.__doc__ or "").strip() or None,
enabled=True, enabled=True,
registered_at=now, registered_at=now,
compatible_categories=action.compatible_categories,
) )
.on_conflict_do_nothing(index_elements=["name"]) .on_conflict_do_nothing(index_elements=["name"])
) )
await db.execute(stmt) await db.execute(stmt)
# Load enabled plugins from DB # Load all plugins from DB; apply stored categories to action instances
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await db.execute(select(Plugin.name).where(Plugin.enabled == True)) result = await db.execute(
enabled_names = {row[0] for row in result.all()} select(Plugin.name, Plugin.enabled, Plugin.compatible_categories)
)
rows = result.all()
# Register only enabled actions enabled_count = 0
for name, action in BUILTIN_ACTIONS.items(): for name, enabled, categories in rows:
if name in enabled_names: action = BUILTIN_ACTIONS.get(name)
if action is None:
continue
# Override in-memory categories with whatever is stored in DB
action.compatible_categories = categories or []
if enabled:
action_registry.register(action) action_registry.register(action)
enabled_count += 1
print(f"[plugins] Registered {len(enabled_names)}/{len(BUILTIN_ACTIONS)} plugin(s)") print(f"[plugins] Registered {enabled_count}/{len(BUILTIN_ACTIONS)} plugin(s)")

View file

@ -58,25 +58,29 @@ async def update_plugin(
if plugin is None: if plugin is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
plugin.enabled = body.enabled
# Hot-reload the registry immediately
if body.enabled:
action = BUILTIN_ACTIONS.get(name) action = BUILTIN_ACTIONS.get(name)
if body.enabled is not None:
plugin.enabled = body.enabled
if body.enabled:
if action: if action:
action_registry.register(action) action_registry.register(action)
else: else:
action_registry.unregister(name) action_registry.unregister(name)
if body.compatible_categories is not None:
plugin.compatible_categories = body.compatible_categories
if action:
action.compatible_categories = body.compatible_categories
await db.flush() await db.flush()
action = BUILTIN_ACTIONS.get(name)
return PluginRead( return PluginRead(
name=plugin.name, name=plugin.name,
display_name=plugin.display_name, display_name=plugin.display_name,
description=plugin.description, description=plugin.description,
enabled=plugin.enabled, enabled=plugin.enabled,
registered_at=plugin.registered_at, registered_at=plugin.registered_at,
compatible_categories=action.compatible_categories if action else [], compatible_categories=plugin.compatible_categories,
) )

View file

@ -18,7 +18,8 @@ class PluginRead(BaseModel):
class PluginUpdate(BaseModel): class PluginUpdate(BaseModel):
enabled: bool enabled: Optional[bool] = None
compatible_categories: Optional[list[str]] = None
# ── Users ────────────────────────────────────────────────────────────────────── # ── Users ──────────────────────────────────────────────────────────────────────

View file

@ -21,8 +21,18 @@ export const usePlugins = () =>
export const useUpdatePlugin = () => { export const useUpdatePlugin = () => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) => mutationFn: ({
api.patch<PluginItem>(`/api/v1/admin/plugins/${name}`, { enabled }).then((r) => r.data), name,
enabled,
compatible_categories,
}: {
name: string
enabled?: boolean
compatible_categories?: string[]
}) =>
api
.patch<PluginItem>(`/api/v1/admin/plugins/${name}`, { enabled, compatible_categories })
.then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-plugins'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-plugins'] }),
}) })
} }

View file

@ -6,6 +6,7 @@ import {
PlusOutlined, PlusOutlined,
UserOutlined, UserOutlined,
ApiOutlined, ApiOutlined,
SettingOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
Badge, Badge,
@ -45,11 +46,20 @@ import {
import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials' import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials'
import { stripEmpty } from '../utils/form' import { stripEmpty } from '../utils/form'
const ALL_DEVICE_CATEGORIES = [
'main_server', 'vm', 'router', 'embedded', 'camera',
'io_board', 'bank_terminal', 'cash_register', 'other',
'server', 'raspberry', 'mikrotik',
]
// ── Plugins Tab ─────────────────────────────────────────────────────────────── // ── Plugins Tab ───────────────────────────────────────────────────────────────
function PluginsTab() { function PluginsTab() {
const { data: plugins, isLoading } = usePlugins() const { data: plugins, isLoading } = usePlugins()
const updatePlugin = useUpdatePlugin() const updatePlugin = useUpdatePlugin()
const [editTarget, setEditTarget] = useState<(typeof plugins)[0] | null>(null)
const [editCategories, setEditCategories] = useState<string[]>([])
const toggle = async (name: string, enabled: boolean) => { const toggle = async (name: string, enabled: boolean) => {
try { try {
await updatePlugin.mutateAsync({ name, enabled }) await updatePlugin.mutateAsync({ name, enabled })
@ -59,6 +69,22 @@ function PluginsTab() {
} }
} }
const openEdit = (row: (typeof plugins)[0]) => {
setEditTarget(row)
setEditCategories(row.compatible_categories)
}
const handleSaveCategories = async () => {
if (!editTarget) return
try {
await updatePlugin.mutateAsync({ name: editTarget.name, compatible_categories: editCategories })
message.success('Категории обновлены')
setEditTarget(null)
} catch {
message.error('Ошибка при сохранении')
}
}
const columns = [ const columns = [
{ {
title: 'Название', title: 'Название',
@ -80,13 +106,21 @@ function PluginsTab() {
}, },
{ {
title: 'Категории', title: 'Категории',
dataIndex: 'compatible_categories',
key: 'categories', key: 'categories',
render: (cats: string[]) => render: (_: unknown, row: (typeof plugins)[0]) => (
cats.length === 0 ? ( <Space wrap>
{row.compatible_categories.length === 0 ? (
<Tag color="default">Все</Tag> <Tag color="default">Все</Tag>
) : ( ) : (
cats.map((c) => <Tag key={c}>{c}</Tag>) row.compatible_categories.map((c) => <Tag key={c}>{c}</Tag>)
)}
<Button
size="small"
type="text"
icon={<SettingOutlined />}
onClick={() => openEdit(row)}
/>
</Space>
), ),
}, },
{ {
@ -106,6 +140,7 @@ function PluginsTab() {
] ]
return ( return (
<>
<Table <Table
dataSource={plugins ?? []} dataSource={plugins ?? []}
columns={columns} columns={columns}
@ -114,6 +149,31 @@ function PluginsTab() {
size="middle" size="middle"
pagination={false} pagination={false}
/> />
<Modal
title={editTarget ? `Категории: ${editTarget.display_name}` : ''}
open={!!editTarget}
onCancel={() => setEditTarget(null)}
onOk={handleSaveCategories}
confirmLoading={updatePlugin.isPending}
okText="Сохранить"
cancelText="Отмена"
>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Выберите категории устройств, к которым применяется этот плагин.
Пустой список означает «все категории».
</Typography.Text>
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="Все категории (без ограничений)"
value={editCategories}
onChange={setEditCategories}
options={ALL_DEVICE_CATEGORIES.map((c) => ({ value: c, label: c }))}
allowClear
/>
</Modal>
</>
) )
} }

View file

@ -453,8 +453,8 @@ export function ObjectDetailPage({ defaultMode = 'inventory' }: { defaultMode?:
) )
} }
const pauseIcon = !d.monitor_enabled const pauseIcon = d.monitor_enabled
? <Tooltip title="Мониторинг выключен"><span style={{ color: '#faad14' }}></span></Tooltip> ? <Tooltip title="Мониторинг включен"><span style={{ color: '#faad14' }}></span></Tooltip>
: null : null
const lastSeenHint = d.last_seen && d.last_ping_at && d.last_ping_at !== d.last_seen const lastSeenHint = d.last_seen && d.last_ping_at && d.last_ping_at !== d.last_seen