Add custom definitions console

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funnywolf
2026-06-28 15:09:10 +08:00
co-authored by Copilot
parent 3ca53ecef8
commit da94d2f194
14 changed files with 876 additions and 230 deletions
+1 -1
Submodule asf-doc updated: 174fc37949...33d5d5623a
-1
View File
@@ -14,7 +14,6 @@ class BasePlaybook:
DESC = ""
TAGS = []
PROMPT_SLUG = ""
REQUIRED_PROMPTS = []
SCRIPT_PATH = None
def __init__(self, *, playbook_run=None):
+120 -61
View File
@@ -1,12 +1,14 @@
from pathlib import Path
import redis
from django.conf import settings
from apps.agentic.runtime.module import scan_module_definitions
from apps.agentic.runtime.module import AGENTIC_MODULE_CONSUMER_GROUP, scan_module_definitions
from apps.agentic.services.playbooks import scan_playbook_definitions
from apps.common.redis_stream import RedisStreamClient
from integrations.siem.registry import reload_registry, scan_registry_configs
PROMPT_LANGUAGES = ("en", "zh")
MAX_STREAM_MESSAGES = 20
def _source_for_path(path):
@@ -22,10 +24,10 @@ def _source_for_path(path):
def _module_record(definition):
return {
"name": definition.name,
"description": getattr(definition.module_class, "DESC", ""),
"stream_name": definition.stream_name,
"thread_num": definition.thread_num,
"path": str(definition.path),
"source": _source_for_path(definition.path),
}
@@ -42,74 +44,131 @@ def _playbook_record(definition):
def _siem_record(item):
path = item["path"]
return item
def _decode(value):
return value.decode() if isinstance(value, bytes) else value
def _info_get(info, key, default=None):
return info.get(key, info.get(key.encode(), default))
def _stream_group_record(group):
return {
**item,
"source": _source_for_path(path),
"name": _decode(_info_get(group, "name", "")),
"consumers": _info_get(group, "consumers", 0),
"pending": _info_get(group, "pending", 0),
"last_delivered_id": _decode(_info_get(group, "last-delivered-id", "")),
}
def _prompt_record(definition, prompt_name, language, path):
def _entry_id(entry):
if isinstance(entry, (list, tuple)) and entry:
return _decode(entry[0])
return ""
def _stream_health(stream_name, *, redis_client=None):
try:
client = redis_client or RedisStreamClient()
info = client.stream_info(stream_name)
groups = client.stream_groups(stream_name)
except redis.ResponseError as exc:
if "no such key" in str(exc).lower():
return {
"available": False,
"length": 0,
"first_id": "",
"last_id": "",
"groups": [],
"warning": "Stream does not exist yet.",
}
raise
return {
"playbook": definition.name,
"prompt": prompt_name,
"language": language,
"path": str(path),
"source": "custom",
"available": True,
"length": _info_get(info, "length", 0),
"first_id": _entry_id(_info_get(info, "first-entry")),
"last_id": _entry_id(_info_get(info, "last-entry")),
"groups": [_stream_group_record(group) for group in groups],
"warning": "",
}
def _scan_playbook_prompts(playbooks):
items = []
errors = []
for definition in playbooks:
if _source_for_path(definition.path) != "custom":
continue
required_prompts = getattr(definition.script_class, "REQUIRED_PROMPTS", []) or []
for prompt_name in required_prompts:
for language in PROMPT_LANGUAGES:
path = definition.script_class.prompt_path(prompt_name, language=language)
if path.exists():
items.append(_prompt_record(definition, prompt_name, language, path))
else:
errors.append({
"path": str(path),
"error": f"Missing custom playbook prompt: {definition.name} {prompt_name}_{language}",
})
return items, errors
def _module_record_with_stream_health(definition, *, redis_client=None):
record = _module_record(definition)
try:
record["stream_health"] = _stream_health(definition.stream_name, redis_client=redis_client)
except redis.RedisError as exc:
record["stream_health"] = {
"available": False,
"length": 0,
"first_id": "",
"last_id": "",
"groups": [],
"warning": f"{type(exc).__name__}: {exc}",
}
return record
def refresh_custom_definitions():
reload_registry()
modules, module_errors = scan_module_definitions()
playbooks, playbook_errors = scan_playbook_definitions()
siem_indices, siem_errors = scan_registry_configs()
prompt_items, prompt_errors = _scan_playbook_prompts(playbooks)
sections = {
"modules": {
"items": [_module_record(item) for item in modules],
"errors": module_errors,
},
"playbooks": {
"items": [_playbook_record(item) for item in playbooks],
"errors": playbook_errors,
},
"siem": {
"items": [_siem_record(item) for item in siem_indices],
"errors": siem_errors,
},
"prompts": {
"items": prompt_items,
"errors": prompt_errors,
def _section_result(section, items, errors):
return {
"section": section,
"success": not errors,
"counts": {
"items": len(items),
"errors": len(errors),
},
"items": items,
"errors": errors,
}
result = dict(sections)
result["success"] = not any(section["errors"] for section in sections.values())
result["counts"] = {
"modules": len(result["modules"]["items"]),
"playbooks": len(result["playbooks"]["items"]),
"siem": len(result["siem"]["items"]),
"prompts": len(result["prompts"]["items"]),
"errors": sum(len(section["errors"]) for section in sections.values()),
def list_module_definitions_with_health():
modules, errors = scan_module_definitions()
items = [
_module_record_with_stream_health(definition)
for definition in modules
]
return _section_result("modules", items, errors)
def list_playbook_definition_records():
playbooks, errors = scan_playbook_definitions()
items = [_playbook_record(item) for item in playbooks]
return _section_result("playbooks", items, errors)
def list_siem_definition_records(*, reload=False):
if reload:
reload_registry()
siem_indices, errors = scan_registry_configs()
items = [_siem_record(item) for item in siem_indices]
return _section_result("siem", items, errors)
def known_module_streams():
modules, _errors = scan_module_definitions()
return {definition.stream_name for definition in modules}
def read_module_stream_recent(stream_name, limit):
limit = max(1, min(int(limit or 5), MAX_STREAM_MESSAGES))
messages = RedisStreamClient().read_stream_recent(stream_name, limit)
return {
"stream_name": stream_name,
"consumer_group": AGENTIC_MODULE_CONSUMER_GROUP,
"limit": limit,
"messages": messages,
}
def read_module_stream_message(stream_name, message_id):
message = RedisStreamClient().read_stream_message_by_id(stream_name, message_id)
return {
"stream_name": stream_name,
"message_id": message_id,
"message": message,
}
return result
+10
View File
@@ -80,6 +80,10 @@ class RedisStreamClient:
messages = self.redis.xrange(stream, min="-", max="+", count=count)
return [_decode_stream_message(message_id, fields, stream=stream) for message_id, fields in messages]
def read_stream_recent(self, stream, count):
messages = self.redis.xrevrange(stream, max="+", min="-", count=count)
return [_decode_stream_message(message_id, fields, stream=stream) for message_id, fields in messages]
def read_stream_message_by_id(self, stream, message_id):
messages = self.redis.xrange(stream, min=message_id, max=message_id, count=1)
if not messages:
@@ -87,5 +91,11 @@ class RedisStreamClient:
found_id, fields = messages[0]
return _decode_stream_message(found_id, fields, stream=stream)
def stream_info(self, stream):
return self.redis.xinfo_stream(stream)
def stream_groups(self, stream):
return self.redis.xinfo_groups(stream)
def delete_stream(self, stream):
return bool(self.redis.delete(stream))
+119
View File
@@ -0,0 +1,119 @@
import redis
from django.contrib.contenttypes.models import ContentType
from rest_framework import permissions, status, views
from rest_framework.response import Response
from apps.accounts.permissions import IsAdmin
from apps.agentic.services.custom import (
MAX_STREAM_MESSAGES,
known_module_streams,
list_module_definitions_with_health,
list_playbook_definition_records,
list_siem_definition_records,
read_module_stream_message,
read_module_stream_recent,
)
from apps.audit.models import AuditLog
from .models import RuntimeConfig
def _audit_refresh(section, request, result):
instance = RuntimeConfig.get_current()
AuditLog.objects.create(
content_type=ContentType.objects.get_for_model(RuntimeConfig),
object_id=str(instance.pk),
action="refresh",
actor=request.user if getattr(request.user, "is_authenticated", False) else None,
metadata={
"section": section,
"success": result["success"],
"counts": result["counts"],
},
)
class CustomDefinitionsModuleView(views.APIView):
permission_classes = [permissions.IsAuthenticated, IsAdmin]
def get(self, request):
return Response(list_module_definitions_with_health(), status=status.HTTP_200_OK)
def post(self, request):
result = list_module_definitions_with_health()
_audit_refresh("modules", request, result)
return Response(result, status=status.HTTP_200_OK)
class CustomDefinitionsPlaybookView(views.APIView):
permission_classes = [permissions.IsAuthenticated, IsAdmin]
def get(self, request):
return Response(list_playbook_definition_records(), status=status.HTTP_200_OK)
def post(self, request):
result = list_playbook_definition_records()
_audit_refresh("playbooks", request, result)
return Response(result, status=status.HTTP_200_OK)
class CustomDefinitionsSiemView(views.APIView):
permission_classes = [permissions.IsAuthenticated, IsAdmin]
def get(self, request):
return Response(list_siem_definition_records(), status=status.HTTP_200_OK)
def post(self, request):
result = list_siem_definition_records(reload=True)
_audit_refresh("siem", request, result)
return Response(result, status=status.HTTP_200_OK)
def _stream_name(request):
stream_name = str(request.query_params.get("stream_name") or "").strip()
if not stream_name:
return None, Response({"stream_name": ["This query parameter is required."]}, status=status.HTTP_400_BAD_REQUEST)
if stream_name not in known_module_streams():
return None, Response({"detail": "Unknown module stream."}, status=status.HTTP_400_BAD_REQUEST)
return stream_name, None
def _stream_limit(request):
raw_limit = request.query_params.get("limit", 5)
try:
return max(1, min(int(raw_limit), MAX_STREAM_MESSAGES)), None
except (TypeError, ValueError):
return None, Response({"limit": ["Limit must be an integer."]}, status=status.HTTP_400_BAD_REQUEST)
class CustomModuleStreamMessagesView(views.APIView):
permission_classes = [permissions.IsAuthenticated, IsAdmin]
def get(self, request):
stream_name, error = _stream_name(request)
if error is not None:
return error
limit, error = _stream_limit(request)
if error is not None:
return error
try:
result = read_module_stream_recent(stream_name, limit)
except redis.RedisError as exc:
return Response({"detail": f"{type(exc).__name__}: {exc}"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response(result, status=status.HTTP_200_OK)
class CustomModuleStreamMessageView(views.APIView):
permission_classes = [permissions.IsAuthenticated, IsAdmin]
def get(self, request):
stream_name, error = _stream_name(request)
if error is not None:
return error
message_id = str(request.query_params.get("message_id") or "").strip()
if not message_id:
return Response({"message_id": ["This query parameter is required."]}, status=status.HTTP_400_BAD_REQUEST)
try:
result = read_module_stream_message(stream_name, message_id)
except redis.RedisError as exc:
return Response({"detail": f"{type(exc).__name__}: {exc}"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response(result, status=status.HTTP_200_OK)
+12 -2
View File
@@ -1,12 +1,18 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .custom_views import (
CustomDefinitionsModuleView,
CustomDefinitionsPlaybookView,
CustomDefinitionsSiemView,
CustomModuleStreamMessageView,
CustomModuleStreamMessagesView,
)
from .views import (
LLMProviderConfigViewSet,
LdapConfigView,
LdapTestView,
RuntimeConfigView,
RuntimeCustomDefinitionsRefreshView,
SiemElkConfigView,
SiemElkTestView,
SiemSplunkConfigView,
@@ -29,6 +35,10 @@ urlpatterns = [
path("settings/ldap/", LdapConfigView.as_view(), name="ldap-config"),
path("settings/ldap/test/", LdapTestView.as_view(), name="ldap-test"),
path("settings/runtime/", RuntimeConfigView.as_view(), name="runtime-config"),
path("settings/runtime/custom-definitions/refresh/", RuntimeCustomDefinitionsRefreshView.as_view(), name="runtime-custom-definitions-refresh"),
path("custom/modules/", CustomDefinitionsModuleView.as_view(), name="custom-definitions-modules"),
path("custom/modules/stream/messages/", CustomModuleStreamMessagesView.as_view(), name="custom-module-stream-messages"),
path("custom/modules/stream/message/", CustomModuleStreamMessageView.as_view(), name="custom-module-stream-message"),
path("custom/playbooks/", CustomDefinitionsPlaybookView.as_view(), name="custom-definitions-playbooks"),
path("custom/siem/", CustomDefinitionsSiemView.as_view(), name="custom-definitions-siem"),
path("settings/", include(router.urls)),
]
-18
View File
@@ -8,7 +8,6 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.response import Response
from apps.accounts.permissions import IsAdmin
from apps.agentic.services.custom import refresh_custom_definitions
from apps.audit.models import AuditLog
from apps.common.advanced_filters import AdvancedFilterBackend
from .models import (
@@ -389,20 +388,3 @@ class RuntimeConfigView(views.APIView):
transaction.on_commit(lambda: invalidate("runtime"))
return Response(RuntimeConfigSerializer(instance).data)
class RuntimeCustomDefinitionsRefreshView(views.APIView):
permission_classes = [permissions.IsAuthenticated, IsAdmin]
def post(self, request):
result = refresh_custom_definitions()
instance = RuntimeConfig.get_current()
_write_audit(
instance,
"refresh",
request.user,
metadata={
"success": result["success"],
"counts": result["counts"],
},
)
return Response(result, status=status.HTTP_200_OK)
-1
View File
@@ -13,7 +13,6 @@ class Playbook(BasePlaybook):
DESC = "Generate a concise analyst-facing summary for the linked Case."
TAGS = ["Custom", "LLM", "Case"]
PROMPT_SLUG = "case_summary"
REQUIRED_PROMPTS = ["System"]
def run(self):
if self.case is None:
+10 -1
View File
@@ -57,7 +57,16 @@ def scan_registry_configs():
except Exception as exc:
errors.append({"path": str(yaml_file), "error": f"{type(exc).__name__}: {exc}"})
continue
indices.append({"name": index_info.name, "backend": index_info.backend, "path": str(yaml_file)})
fields = [field.model_dump() for field in index_info.fields]
indices.append({
"name": index_info.name,
"backend": index_info.backend,
"description": index_info.description,
"path": str(yaml_file),
"field_count": len(fields),
"key_field_count": sum(1 for field in index_info.fields if field.is_key_field),
"fields": fields,
})
return indices, errors
@@ -25,7 +25,7 @@ Prompt 文件不再作为 Custom Definitions 的管理对象。Playbook 可以
## 目标
- 让管理员可以从独立入口查看所有已加载 definition,并区分 `official` / `custom` 来源。
- 让管理员可以从独立入口查看所有已加载 definition。Playbook 需要区分 `official` / `custom` 来源Modules 和 SIEM YAML 没有 official 来源概念,不展示 source
- 将验证功能按 tab 拆分,避免一个聚合结果难以定位问题。
- 让 Module 页面展示对应 Redis Stream 的基础运行信息,并支持只读查看最近消息或指定消息。
- 让 Playbook 页面展示可运行定义,但不在 Custom 页面直接执行 playbook。
@@ -89,7 +89,6 @@ Module section 返回:
- `name`
- `description`
- `source`
- `path`
- `stream_name`
- `thread_num`
@@ -139,7 +138,6 @@ SIEM section 返回:
- `name`
- `backend`
- `description`
- `source`
- `path`
- `field_count`
- `key_field_count`
@@ -163,13 +161,11 @@ Toolbar
- `Refresh / Validate`
- `Reload`
- source filter
- search
主表字段:
- Module name
- source
- description
- stream_name
- thread_num
@@ -208,7 +204,6 @@ Toolbar
动作:
- `Copy name`
- Playbook 运行记录跳转留到后续单独设计,本次不实现 URL 过滤或 deep link。
### SIEM YAML tab
@@ -218,7 +213,6 @@ Toolbar
- `Refresh / Validate`
- `Reload`
- backend filter
- source filter
- search
主表字段:
@@ -226,7 +220,6 @@ Toolbar
- index name
- backend
- description
- source
- field count
- key field count
- path
@@ -235,7 +228,7 @@ Toolbar
- name
- type
- key
- key field
- description
- sample values
@@ -292,7 +285,7 @@ Toolbar
- Runtime 页不再显示 Custom Definitions。
- 每个 tab 可单独 Refresh / Validate 并展示本 section errors。
- Modules 详情抽屉可读取最近 stream 消息和指定 message id。
- Playbooks tab 只展示和复制,不直接执行 playbook。
- Playbooks tab 只展示,不直接执行 playbook。
- SIEM YAML 详情抽屉展示 fields 表。
文档:
+2
View File
@@ -11,6 +11,7 @@ import PlaybookList from './pages/PlaybookList'
import KnowledgeList from './pages/KnowledgeList'
import Dashboard from './pages/Dashboard'
import SystemSettings from './pages/SystemSettings'
import CustomDefinitions from './pages/CustomDefinitions'
import {useAuthStore} from './stores/auth'
import {hasPermission, type PermissionKey} from './utils/permissions'
import {getMe} from './api/auth'
@@ -56,6 +57,7 @@ export default function App() {
<Route path="playbooks/:rowId" element={<ResourceDetailRoute resourceKey="playbooks" />} />
<Route path="knowledge" element={<KnowledgeList />} />
<Route path="knowledge/:rowId" element={<ResourceDetailRoute resourceKey="knowledge" />} />
<Route path="custom" element={<PermissionRoute permission="admin"><CustomDefinitions /></PermissionRoute>} />
<Route path="system" element={<PermissionRoute permission="admin"><SystemSettings /></PermissionRoute>} />
<Route path="system/users" element={<Navigate to="/system" replace />} />
</Route>
+4 -2
View File
@@ -7,7 +7,7 @@ import {
MenuUnfoldOutlined,
UserOutlined,
} from '@ant-design/icons'
import {BookOpenText, BrainCircuit, BriefcaseBusiness, Fingerprint, LayoutDashboard, Settings, Siren, WandSparkles} from 'lucide-react'
import {BookOpenText, BrainCircuit, BriefcaseBusiness, Fingerprint, LayoutDashboard, Puzzle, Settings, Siren, WandSparkles} from 'lucide-react'
import {useAuthStore} from '../stores/auth'
import {getResourceConfig} from '../config/resources'
import type {ResourceConfig} from '../types/records'
@@ -28,6 +28,7 @@ const breadcrumbMap: Record<string, string> = {
enrichments: 'Enrichments',
playbooks: 'Playbooks',
knowledge: 'Knowledge',
custom: 'Custom',
dashboard: 'Dashboard',
system: 'Setting',
}
@@ -63,7 +64,7 @@ export default function MainLayout() {
}, [location.pathname])
const selectedKey = (() => {
const allKeys = ['/dashboard', '/cases', '/alerts', '/artifacts', '/enrichments', '/playbooks', '/knowledge', '/system']
const allKeys = ['/dashboard', '/cases', '/alerts', '/artifacts', '/enrichments', '/playbooks', '/knowledge', '/custom', '/system']
if (allKeys.includes(location.pathname)) return location.pathname
return '/' + (location.pathname.split('/').filter(Boolean)[0] || 'cases')
})()
@@ -77,6 +78,7 @@ export default function MainLayout() {
{ key: '/enrichments', icon: <WandSparkles {...lucideIconProps} />, label: 'Enrichments' },
{ key: '/playbooks', icon: <BrainCircuit {...lucideIconProps} />, label: 'Playbooks' },
{ key: '/knowledge', icon: <BookOpenText {...lucideIconProps} />, label: 'Knowledge' },
hasPermission(user, 'admin') ? { key: '/custom', icon: <Puzzle {...lucideIconProps} />, label: 'Custom' } : null,
hasPermission(user, 'admin') ? { key: '/system', icon: <Settings {...lucideIconProps} />, label: 'Setting' } : null,
].filter((item): item is NonNullable<typeof item> => Boolean(item))
+594
View File
@@ -0,0 +1,594 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {Alert, Button, Descriptions, Drawer, Empty, Flex, Input, InputNumber, List, message, Select, Space, Table, Tabs, Tag, Typography} from 'antd'
import {ReloadOutlined} from '@ant-design/icons'
import type {ColumnsType} from 'antd/es/table'
import {Boxes, BrainCircuit, DatabaseZap} from 'lucide-react'
import client from '../api/client'
import JsonViewer from '../components/JsonViewer'
import IconTabLabel from '../components/IconTabLabel'
import {comfortableTagProps} from '../utils/tagStyles'
type SourceType = 'official' | 'custom'
type SourceFilter = 'all' | SourceType
interface DefinitionError {
path: string
error: string
}
interface SectionResult<T> {
section: string
success: boolean
counts: {
items: number
errors: number
}
items: T[]
errors: DefinitionError[]
}
interface StreamGroup {
name: string
consumers: number
pending: number
last_delivered_id: string
}
interface StreamHealth {
available: boolean
length: number
first_id: string
last_id: string
groups: StreamGroup[]
warning: string
}
interface ModuleDefinition {
name: string
description: string
path: string
stream_name: string
thread_num: number
stream_health: StreamHealth
}
interface PlaybookDefinition {
name: string
description: string
tags: string[]
source: SourceType
path: string
}
interface SiemField {
name: string
type: string
description: string
is_key_field: boolean
sample_values: unknown[]
}
interface SiemDefinition {
name: string
backend: 'ELK' | 'Splunk'
description: string
path: string
field_count: number
key_field_count: number
fields: SiemField[]
}
interface StreamMessage {
message_id: string
data: unknown
}
interface StreamMessagesResponse {
stream_name: string
consumer_group: string
limit: number
messages: StreamMessage[]
}
interface StreamMessageResponse {
stream_name: string
message_id: string
message: StreamMessage | Record<string, never>
}
function apiErrorMessage(error: unknown, fallback: string) {
const data = (error as { response?: { data?: unknown } }).response?.data
if (typeof data === 'string') return data
if (data && typeof data === 'object') {
const detail = (data as { detail?: unknown }).detail
if (typeof detail === 'string') return detail
return JSON.stringify(data)
}
return fallback
}
function sourceOptions(): Array<{ label: string; value: SourceFilter }> {
return [
{ label: 'All sources', value: 'all' },
{ label: 'Official', value: 'official' },
{ label: 'Custom', value: 'custom' },
]
}
function SourceTag({ source }: { source: SourceType }) {
return <Tag {...comfortableTagProps} color={source === 'custom' ? 'blue' : 'gold'}>{source}</Tag>
}
function PathText({ path }: { path: string }) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis={{ tooltip: path }}>
{path}
</Typography.Text>
)
}
function filterBySource<T extends { source: SourceType }>(items: T[], source: SourceFilter) {
return source === 'all' ? items : items.filter((item) => item.source === source)
}
const PLAYBOOK_TAG_COLORS: Record<string, string> = {
System: 'gold',
LLM: 'purple',
Case: 'green',
Knowledge: 'magenta',
CMDB: 'geekblue',
'Threat Intel': 'volcano',
Enrichment: 'cyan',
Custom: 'blue',
}
const SIEM_BACKEND_COLORS: Record<string, string> = {
ELK: 'orange',
Splunk: 'green',
}
function includesSearch(values: unknown[], search: string) {
const keyword = search.trim().toLowerCase()
if (!keyword) return true
return values.some((value) => String(value || '').toLowerCase().includes(keyword))
}
function DefinitionErrors({ errors }: { errors: DefinitionError[] }) {
if (!errors.length) return null
return (
<Flex vertical gap={8} style={{ width: '100%', marginBottom: 12 }}>
{errors.map((error) => (
<Alert key={`${error.path}-${error.error}`} type="error" showIcon title={error.path} description={error.error} />
))}
</Flex>
)
}
function useDefinitionSection<T>(endpoint: string, label: string) {
const [result, setResult] = useState<SectionResult<T> | null>(null)
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await client.get<SectionResult<T>>(endpoint)
setResult(data)
} catch (error) {
message.error(apiErrorMessage(error, `Failed to load ${label}`))
setResult(null)
} finally {
setLoading(false)
}
}, [endpoint, label])
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const { data } = await client.post<SectionResult<T>>(endpoint)
setResult(data)
if (data.success) {
message.success(`${label} refreshed`)
} else {
message.warning(`${label} refreshed with ${data.counts.errors} error(s)`)
}
} catch (error) {
message.error(apiErrorMessage(error, `Failed to refresh ${label}`))
} finally {
setRefreshing(false)
}
}, [endpoint, label])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load()
}, [load])
return { result, loading, refreshing, load, refresh }
}
function SectionToolbar({
search,
onSearchChange,
source,
onSourceChange,
onReload,
onRefresh,
loading,
refreshing,
extra,
}: {
search: string
onSearchChange: (value: string) => void
source?: SourceFilter
onSourceChange?: (value: SourceFilter) => void
onReload: () => void
onRefresh: () => void
loading: boolean
refreshing: boolean
extra?: React.ReactNode
}) {
return (
<Space wrap style={{ marginBottom: 12 }}>
<Button type="primary" onClick={onRefresh} loading={refreshing}>Refresh / Validate</Button>
<Button icon={<ReloadOutlined />} onClick={onReload} loading={loading}>Reload</Button>
{source && onSourceChange ? <Select<SourceFilter> value={source} options={sourceOptions()} onChange={onSourceChange} style={{ width: 140 }} /> : null}
{extra}
<Input.Search allowClear placeholder="Search" value={search} onChange={(event) => onSearchChange(event.target.value)} style={{ width: 280 }} />
</Space>
)
}
function ModuleDrawer({ module, open, onClose }: { module: ModuleDefinition | null; open: boolean; onClose: () => void }) {
const [messages, setMessages] = useState<StreamMessage[]>([])
const [messageLimit, setMessageLimit] = useState(5)
const [messageId, setMessageId] = useState('')
const [selectedMessage, setSelectedMessage] = useState<StreamMessage | Record<string, never> | null>(null)
const [loadingMessages, setLoadingMessages] = useState(false)
const [messageError, setMessageError] = useState('')
const streamName = module?.stream_name || ''
const loadRecent = useCallback(async () => {
if (!streamName) return
setLoadingMessages(true)
setMessageError('')
try {
const { data } = await client.get<StreamMessagesResponse>('/custom/modules/stream/messages/', {
params: { stream_name: streamName, limit: messageLimit },
})
setMessages(data.messages)
} catch (error) {
setMessages([])
setMessageError(apiErrorMessage(error, 'Failed to load stream messages'))
} finally {
setLoadingMessages(false)
}
}, [messageLimit, streamName])
useEffect(() => {
if (!open || !streamName) return
// eslint-disable-next-line react-hooks/set-state-in-effect
setMessages([])
setSelectedMessage(null)
setMessageId('')
loadRecent()
}, [loadRecent, open, streamName])
const loadById = async () => {
if (!module?.stream_name || !messageId.trim()) return
setLoadingMessages(true)
setMessageError('')
try {
const { data } = await client.get<StreamMessageResponse>('/custom/modules/stream/message/', {
params: { stream_name: module.stream_name, message_id: messageId.trim() },
})
setSelectedMessage(data.message)
} catch (error) {
setSelectedMessage(null)
setMessageError(apiErrorMessage(error, 'Failed to load stream message'))
} finally {
setLoadingMessages(false)
}
}
return (
<Drawer title={module?.name || 'Module'} open={open} onClose={onClose} size="min(920px, calc(100vw - 48px))">
{module ? (
<Flex vertical gap={16} style={{ width: '100%' }}>
<Descriptions size="small" column={1} bordered items={[
{ key: 'description', label: 'Description', children: module.description || '—' },
{ key: 'stream', label: 'Stream', children: <Typography.Text copyable>{module.stream_name}</Typography.Text> },
{ key: 'threads', label: 'Threads', children: module.thread_num },
{ key: 'path', label: 'Path', children: <PathText path={module.path} /> },
]} />
<Descriptions size="small" title="Stream health" column={2} bordered items={[
{ key: 'available', label: 'Available', children: module.stream_health.available ? 'Yes' : 'No' },
{ key: 'length', label: 'Length', children: module.stream_health.length },
{ key: 'first', label: 'First ID', children: module.stream_health.first_id || '—' },
{ key: 'last', label: 'Last ID', children: module.stream_health.last_id || '—' },
]} />
{module.stream_health.warning ? <Alert type="warning" showIcon title={module.stream_health.warning} /> : null}
{module.stream_health.groups.length ? (
<Table<StreamGroup>
size="small"
rowKey="name"
pagination={false}
dataSource={module.stream_health.groups}
columns={[
{ title: 'Group', dataIndex: 'name' },
{ title: 'Consumers', dataIndex: 'consumers', width: 120 },
{ title: 'Pending', dataIndex: 'pending', width: 120 },
{ title: 'Last delivered', dataIndex: 'last_delivered_id', width: 220 },
]}
/>
) : null}
<div>
<Typography.Text strong>Recent messages</Typography.Text>
<Space style={{ marginLeft: 12 }}>
<InputNumber min={1} max={20} value={messageLimit} onChange={(value) => setMessageLimit(Number(value || 5))} />
<Button onClick={loadRecent} loading={loadingMessages}>Load recent</Button>
</Space>
</div>
{messageError ? <Alert type="error" showIcon title={messageError} /> : null}
<List
loading={loadingMessages}
dataSource={messages}
locale={{ emptyText: <Empty description="No messages" /> }}
renderItem={(item) => (
<List.Item>
<Flex vertical gap={4} style={{ width: '100%' }}>
<Typography.Text code>{item.message_id}</Typography.Text>
<JsonViewer value={item.data} maxHeight={260} />
</Flex>
</List.Item>
)}
/>
<div>
<Typography.Text strong>Read by message ID</Typography.Text>
<Input.Search
allowClear
enterButton="Read"
value={messageId}
onChange={(event) => setMessageId(event.target.value)}
onSearch={loadById}
style={{ marginTop: 8 }}
placeholder="1700000000000-0"
/>
</div>
{selectedMessage ? <JsonViewer value={selectedMessage} maxHeight={320} /> : null}
</Flex>
) : null}
</Drawer>
)
}
function ModulesTab() {
const { result, loading, refreshing, load, refresh } = useDefinitionSection<ModuleDefinition>('/custom/modules/', 'Modules')
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<ModuleDefinition | null>(null)
const rows = useMemo(() => (
(result?.items || []).filter((item) => includesSearch([
item.name,
item.description,
item.stream_name,
item.path,
], search))
), [result?.items, search])
const columns = useMemo<ColumnsType<ModuleDefinition>>(() => [
{ title: 'Module', dataIndex: 'name', width: 340, render: (name: string) => <Typography.Text strong>{name}</Typography.Text> },
{ title: 'Description', dataIndex: 'description', ellipsis: true },
{ title: 'Stream', dataIndex: 'stream_name', width: 380, ellipsis: true },
{ title: 'Threads', dataIndex: 'thread_num', width: 100 },
{ title: 'Length', width: 100, render: (_, record) => record.stream_health.length },
{ title: 'Last ID', width: 180, render: (_, record) => record.stream_health.last_id || '—' },
{ title: 'Path', dataIndex: 'path', width: 280, render: (path: string) => <PathText path={path} /> },
], [])
return (
<>
<SectionToolbar
search={search}
onSearchChange={setSearch}
onReload={load}
onRefresh={refresh}
loading={loading}
refreshing={refreshing}
/>
<DefinitionErrors errors={result?.errors || []} />
<Table<ModuleDefinition>
size="small"
rowKey="path"
loading={loading}
columns={columns}
dataSource={rows}
onRow={(record) => ({ onClick: () => setSelected(record), style: { cursor: 'pointer' } })}
locale={{ emptyText: loading ? <span /> : <Empty description="No modules loaded" /> }}
scroll={{ x: 1600 }}
/>
<ModuleDrawer module={selected} open={selected !== null} onClose={() => setSelected(null)} />
</>
)
}
function PlaybooksTab() {
const { result, loading, refreshing, load, refresh } = useDefinitionSection<PlaybookDefinition>('/custom/playbooks/', 'Playbooks')
const [source, setSource] = useState<SourceFilter>('all')
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<PlaybookDefinition | null>(null)
const rows = useMemo(() => (
filterBySource(result?.items || [], source).filter((item) => includesSearch([
item.name,
item.description,
item.path,
...item.tags,
], search))
), [result?.items, search, source])
const columns = useMemo<ColumnsType<PlaybookDefinition>>(() => [
{ title: 'Playbook', dataIndex: 'name', width: 260, render: (name: string) => <Typography.Text strong>{name}</Typography.Text> },
{ title: 'Source', dataIndex: 'source', width: 110, render: (value: SourceType) => <SourceTag source={value} /> },
{
title: 'Tags',
dataIndex: 'tags',
width: 260,
render: (tags: string[]) => (
<Space size={[4, 4]} wrap>
{(tags || []).map((tag) => <Tag {...comfortableTagProps} key={tag} color={PLAYBOOK_TAG_COLORS[tag] || 'blue'}>{tag}</Tag>)}
</Space>
),
},
{ title: 'Description', dataIndex: 'description', ellipsis: true },
{ title: 'Path', dataIndex: 'path', width: 300, render: (path: string) => <PathText path={path} /> },
], [])
return (
<>
<SectionToolbar
search={search}
onSearchChange={setSearch}
source={source}
onSourceChange={setSource}
onReload={load}
onRefresh={refresh}
loading={loading}
refreshing={refreshing}
/>
<DefinitionErrors errors={result?.errors || []} />
<Table<PlaybookDefinition>
size="small"
rowKey="path"
loading={loading}
columns={columns}
dataSource={rows}
onRow={(record) => ({ onClick: () => setSelected(record), style: { cursor: 'pointer' } })}
locale={{ emptyText: loading ? <span /> : <Empty description="No playbooks loaded" /> }}
scroll={{ x: 1300 }}
/>
<Drawer title={selected?.name || 'Playbook'} open={selected !== null} onClose={() => setSelected(null)} size="min(760px, calc(100vw - 48px))">
{selected ? (
<Flex vertical gap={16} style={{ width: '100%' }}>
<Space>
<SourceTag source={selected.source} />
{(selected.tags || []).map((tag) => <Tag {...comfortableTagProps} key={tag} color={PLAYBOOK_TAG_COLORS[tag] || 'blue'}>{tag}</Tag>)}
</Space>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap' }}>{selected.description || 'No description.'}</Typography.Paragraph>
<Descriptions size="small" column={1} bordered items={[
{ key: 'path', label: 'Path', children: <PathText path={selected.path} /> },
]} />
</Flex>
) : null}
</Drawer>
</>
)
}
function SiemTab() {
const { result, loading, refreshing, load, refresh } = useDefinitionSection<SiemDefinition>('/custom/siem/', 'SIEM YAML')
const [backend, setBackend] = useState<'all' | 'ELK' | 'Splunk'>('all')
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<SiemDefinition | null>(null)
const rows = useMemo(() => (
(result?.items || [])
.filter((item) => backend === 'all' || item.backend === backend)
.filter((item) => includesSearch([item.name, item.backend, item.description, item.path], search))
), [backend, result?.items, search])
const columns = useMemo<ColumnsType<SiemDefinition>>(() => [
{ title: 'Index', dataIndex: 'name', width: 220, render: (name: string) => <Typography.Text strong>{name}</Typography.Text> },
{ title: 'Backend', dataIndex: 'backend', width: 120, render: (value: string) => <Tag {...comfortableTagProps} color={SIEM_BACKEND_COLORS[value] || 'default'}>{value}</Tag> },
{ title: 'Description', dataIndex: 'description', ellipsis: true },
{ title: 'Fields', dataIndex: 'field_count', width: 100 },
{ title: 'Key fields', dataIndex: 'key_field_count', width: 120 },
{ title: 'Path', dataIndex: 'path', width: 300, render: (path: string) => <PathText path={path} /> },
], [])
const fieldColumns = useMemo<ColumnsType<SiemField>>(() => [
{ title: 'Name', dataIndex: 'name', width: 220 },
{ title: 'Type', dataIndex: 'type', width: 120 },
{ title: 'Key field', dataIndex: 'is_key_field', width: 110, render: (value: boolean) => value ? <Tag color="blue">Key field</Tag> : '—' },
{ title: 'Description', dataIndex: 'description' },
{
title: 'Sample values',
dataIndex: 'sample_values',
width: 260,
render: (values: unknown[]) => (
<Typography.Text type="secondary" ellipsis={{ tooltip: JSON.stringify(values || []) }}>
{JSON.stringify(values || [])}
</Typography.Text>
),
},
], [])
return (
<>
<SectionToolbar
search={search}
onSearchChange={setSearch}
onReload={load}
onRefresh={refresh}
loading={loading}
refreshing={refreshing}
extra={(
<Select<'all' | 'ELK' | 'Splunk'>
value={backend}
onChange={(value) => setBackend(value)}
style={{ width: 140 }}
options={[
{ label: 'All backends', value: 'all' },
{ label: 'ELK', value: 'ELK' },
{ label: 'Splunk', value: 'Splunk' },
]}
/>
)}
/>
<DefinitionErrors errors={result?.errors || []} />
<Table<SiemDefinition>
size="small"
rowKey="path"
loading={loading}
columns={columns}
dataSource={rows}
onRow={(record) => ({ onClick: () => setSelected(record), style: { cursor: 'pointer' } })}
locale={{ emptyText: loading ? <span /> : <Empty description="No SIEM YAML loaded" /> }}
scroll={{ x: 1400 }}
/>
<Drawer title={selected?.name || 'SIEM YAML'} open={selected !== null} onClose={() => setSelected(null)} size="min(1080px, calc(100vw - 48px))">
{selected ? (
<Flex vertical gap={16} style={{ width: '100%' }}>
<Descriptions size="small" column={1} bordered items={[
{ key: 'backend', label: 'Backend', children: <Tag {...comfortableTagProps} color={SIEM_BACKEND_COLORS[selected.backend] || 'default'}>{selected.backend}</Tag> },
{ key: 'description', label: 'Description', children: selected.description || '—' },
{ key: 'path', label: 'Path', children: <PathText path={selected.path} /> },
]} />
<Table<SiemField>
size="small"
rowKey="name"
columns={fieldColumns}
dataSource={selected.fields || []}
pagination={false}
scroll={{ x: 900, y: 520 }}
/>
</Flex>
) : null}
</Drawer>
</>
)
}
export default function CustomDefinitions() {
return (
<div style={{ height: '100%', minHeight: 0, overflow: 'auto' }}>
<Tabs
items={[
{ key: 'modules', label: <IconTabLabel icon={Boxes}>Modules</IconTabLabel>, children: <ModulesTab /> },
{ key: 'playbooks', label: <IconTabLabel icon={BrainCircuit}>Playbooks</IconTabLabel>, children: <PlaybooksTab /> },
{ key: 'siem', label: <IconTabLabel icon={DatabaseZap}>SIEM YAML</IconTabLabel>, children: <SiemTab /> },
]}
/>
</div>
)
}
+1 -133
View File
@@ -1,5 +1,5 @@
import {useCallback, useEffect, useState} from 'react'
import {Alert, Button, Card, Col, Divider, Form, InputNumber, List, message, Row, Select, Space, Tag, Tooltip, Typography} from 'antd'
import {Button, Card, Col, Divider, Form, InputNumber, message, Row, Select, Space, Tooltip, Typography} from 'antd'
import {QuestionCircleOutlined} from '@ant-design/icons'
import client from '../api/client'
@@ -9,43 +9,6 @@ interface RuntimeConfig {
updated_at?: string
}
interface CustomDefinitionItem {
name?: string
source: 'official' | 'custom'
path: string
stream_name?: string
backend?: string
description?: string
playbook?: string
prompt?: string
language?: string
}
interface CustomDefinitionError {
path: string
error: string
}
interface CustomDefinitionSection {
items: CustomDefinitionItem[]
errors: CustomDefinitionError[]
}
interface CustomDefinitionRefreshResult {
success: boolean
counts: {
modules: number
playbooks: number
siem: number
prompts: number
errors: number
}
modules: CustomDefinitionSection
playbooks: CustomDefinitionSection
siem: CustomDefinitionSection
prompts: CustomDefinitionSection
}
function initialValues(): RuntimeConfig {
return {
prompt_language: 'en',
@@ -77,50 +40,10 @@ function helpLabel(label: string, help: string) {
)
}
function CustomDefinitionSectionView({ title, section }: { title: string; section: CustomDefinitionSection }) {
return (
<div style={{ marginTop: 12 }}>
<Typography.Text strong>{title}</Typography.Text>
<List
size="small"
dataSource={section.items}
locale={{ emptyText: 'No definitions loaded' }}
renderItem={(item) => (
<List.Item>
<Space direction="vertical" size={0}>
<Space wrap>
<Typography.Text>{item.name || item.playbook || item.prompt}</Typography.Text>
<Tag color={item.source === 'custom' ? 'blue' : 'default'}>{item.source}</Tag>
{item.stream_name ? <Tag>{item.stream_name}</Tag> : null}
{item.backend ? <Tag>{item.backend}</Tag> : null}
{item.prompt ? <Tag>{item.prompt}</Tag> : null}
{item.language ? <Tag>{item.language}</Tag> : null}
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{item.path}</Typography.Text>
</Space>
</List.Item>
)}
/>
{section.errors.map((error) => (
<Alert
key={`${title}-${error.path}`}
type="error"
showIcon
style={{ marginTop: 8 }}
message={error.path}
description={error.error}
/>
))}
</div>
)
}
export default function RuntimeSettings() {
const [form] = Form.useForm<RuntimeConfig>()
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [refreshingDefinitions, setRefreshingDefinitions] = useState(false)
const [definitionResult, setDefinitionResult] = useState<CustomDefinitionRefreshResult | null>(null)
const loadConfig = useCallback(async () => {
setLoading(true)
@@ -153,23 +76,6 @@ export default function RuntimeSettings() {
}
}
const refreshDefinitions = async () => {
setRefreshingDefinitions(true)
try {
const { data } = await client.post<CustomDefinitionRefreshResult>('/settings/runtime/custom-definitions/refresh/')
setDefinitionResult(data)
if (data.success) {
message.success('Custom definitions refreshed')
} else {
message.warning(`Custom definitions refreshed with ${data.counts.errors} error(s)`)
}
} catch (error: unknown) {
message.error(apiErrorMessage(error, 'Failed to refresh custom definitions'))
} finally {
setRefreshingDefinitions(false)
}
}
return (
<div style={{ height: '100%', minHeight: 0, overflow: 'auto' }}>
<Card title="Runtime" loading={loading}>
@@ -207,44 +113,6 @@ export default function RuntimeSettings() {
<Space>
<Button type="primary" onClick={saveConfig} loading={saving}>Save</Button>
</Space>
<Typography.Text strong style={{ display: 'block', marginTop: 28 }}>Custom Definitions</Typography.Text>
<Divider style={{ margin: '8px 0 16px' }} />
<Typography.Paragraph type="secondary">
Refresh and validate Module, Playbook, and SIEM YAML definitions after updating files in the custom directory.
Dependency or helper module changes still require reinstalling custom packages and restarting related containers.
</Typography.Paragraph>
<Space>
<Button onClick={refreshDefinitions} loading={refreshingDefinitions}>Refresh / Validate</Button>
</Space>
{definitionResult ? (
<div style={{ marginTop: 16 }}>
<Alert
type={definitionResult.success ? 'success' : 'warning'}
showIcon
message={
definitionResult.success
? 'Definitions loaded successfully'
: `Definitions loaded with ${definitionResult.counts.errors} error(s)`
}
description={`Modules: ${definitionResult.counts.modules}, Playbooks: ${definitionResult.counts.playbooks}, SIEM YAML: ${definitionResult.counts.siem}, Prompts: ${definitionResult.counts.prompts}`}
/>
<Row gutter={16}>
<Col span={6}>
<CustomDefinitionSectionView title="Modules" section={definitionResult.modules} />
</Col>
<Col span={6}>
<CustomDefinitionSectionView title="Playbooks" section={definitionResult.playbooks} />
</Col>
<Col span={6}>
<CustomDefinitionSectionView title="SIEM YAML" section={definitionResult.siem} />
</Col>
<Col span={6}>
<CustomDefinitionSectionView title="Prompts" section={definitionResult.prompts} />
</Col>
</Row>
</div>
) : null}
</Form>
</Card>
</div>