mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
Add custom definitions console
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+1
-1
Submodule asf-doc updated: 174fc37949...33d5d5623a
@@ -14,7 +14,6 @@ class BasePlaybook:
|
||||
DESC = ""
|
||||
TAGS = []
|
||||
PROMPT_SLUG = ""
|
||||
REQUIRED_PROMPTS = []
|
||||
SCRIPT_PATH = None
|
||||
|
||||
def __init__(self, *, playbook_run=None):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 表。
|
||||
|
||||
文档:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user