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

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 sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.dialects.postgresql import ARRAY as PgARRAY
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@ -17,3 +18,6 @@ class Plugin(Base):
description: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean(), nullable=False, default=True)
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:
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 db.begin():
for action in BUILTIN_ACTIONS.values():
@ -86,19 +87,28 @@ async def load_plugins() -> None:
description=(action.__class__.__doc__ or "").strip() or None,
enabled=True,
registered_at=now,
compatible_categories=action.compatible_categories,
)
.on_conflict_do_nothing(index_elements=["name"])
)
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:
result = await db.execute(select(Plugin.name).where(Plugin.enabled == True))
enabled_names = {row[0] for row in result.all()}
result = await db.execute(
select(Plugin.name, Plugin.enabled, Plugin.compatible_categories)
)
rows = result.all()
# Register only enabled actions
for name, action in BUILTIN_ACTIONS.items():
if name in enabled_names:
enabled_count = 0
for name, enabled, categories in rows:
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)
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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
plugin.enabled = body.enabled
action = BUILTIN_ACTIONS.get(name)
# Hot-reload the registry immediately
if body.enabled:
action = BUILTIN_ACTIONS.get(name)
if body.enabled is not None:
plugin.enabled = body.enabled
if body.enabled:
if action:
action_registry.register(action)
else:
action_registry.unregister(name)
if body.compatible_categories is not None:
plugin.compatible_categories = body.compatible_categories
if action:
action_registry.register(action)
else:
action_registry.unregister(name)
action.compatible_categories = body.compatible_categories
await db.flush()
action = BUILTIN_ACTIONS.get(name)
return PluginRead(
name=plugin.name,
display_name=plugin.display_name,
description=plugin.description,
enabled=plugin.enabled,
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):
enabled: bool
enabled: Optional[bool] = None
compatible_categories: Optional[list[str]] = None
# ── Users ──────────────────────────────────────────────────────────────────────

View file

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

View file

@ -6,6 +6,7 @@ import {
PlusOutlined,
UserOutlined,
ApiOutlined,
SettingOutlined,
} from '@ant-design/icons'
import {
Badge,
@ -45,11 +46,20 @@ import {
import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials'
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 ───────────────────────────────────────────────────────────────
function PluginsTab() {
const { data: plugins, isLoading } = usePlugins()
const updatePlugin = useUpdatePlugin()
const [editTarget, setEditTarget] = useState<(typeof plugins)[0] | null>(null)
const [editCategories, setEditCategories] = useState<string[]>([])
const toggle = async (name: string, enabled: boolean) => {
try {
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 = [
{
title: 'Название',
@ -80,14 +106,22 @@ function PluginsTab() {
},
{
title: 'Категории',
dataIndex: 'compatible_categories',
key: 'categories',
render: (cats: string[]) =>
cats.length === 0 ? (
<Tag color="default">Все</Tag>
) : (
cats.map((c) => <Tag key={c}>{c}</Tag>)
),
render: (_: unknown, row: (typeof plugins)[0]) => (
<Space wrap>
{row.compatible_categories.length === 0 ? (
<Tag color="default">Все</Tag>
) : (
row.compatible_categories.map((c) => <Tag key={c}>{c}</Tag>)
)}
<Button
size="small"
type="text"
icon={<SettingOutlined />}
onClick={() => openEdit(row)}
/>
</Space>
),
},
{
title: 'Статус',
@ -106,14 +140,40 @@ function PluginsTab() {
]
return (
<Table
dataSource={plugins ?? []}
columns={columns}
rowKey="name"
loading={isLoading}
size="middle"
pagination={false}
/>
<>
<Table
dataSource={plugins ?? []}
columns={columns}
rowKey="name"
loading={isLoading}
size="middle"
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
? <Tooltip title="Мониторинг выключен"><span style={{ color: '#faad14' }}></span></Tooltip>
const pauseIcon = d.monitor_enabled
? <Tooltip title="Мониторинг включен"><span style={{ color: '#faad14' }}></span></Tooltip>
: null
const lastSeenHint = d.last_seen && d.last_ping_at && d.last_ping_at !== d.last_seen