фикс задач категорий
This commit is contained in:
parent
af1a14a6a5
commit
3c3624d848
8 changed files with 156 additions and 36 deletions
|
|
@ -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")
|
||||
|
|
@ -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="{}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ class PluginRead(BaseModel):
|
|||
|
||||
|
||||
class PluginUpdate(BaseModel):
|
||||
enabled: bool
|
||||
enabled: Optional[bool] = None
|
||||
compatible_categories: Optional[list[str]] = None
|
||||
|
||||
|
||||
# ── Users ──────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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'] }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue