фикс задач категорий
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 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="{}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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'] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue