diff --git a/asp-marketplace b/asp-marketplace index 41982af..164bdcd 160000 --- a/asp-marketplace +++ b/asp-marketplace @@ -1 +1 @@ -Subproject commit 41982af7da3e3d1f1f47207cabd94756358aae6b +Subproject commit 164bdcdd41ee948a576fc888d8ed9c27461f7fe3 diff --git a/backend/apps/agentic/management/commands/run_agentic_playbook_worker.py b/backend/apps/agentic/management/commands/run_agentic_playbook_worker.py index 7440f9d..bc3066e 100644 --- a/backend/apps/agentic/management/commands/run_agentic_playbook_worker.py +++ b/backend/apps/agentic/management/commands/run_agentic_playbook_worker.py @@ -1,9 +1,13 @@ +import logging + from django.core.management.base import BaseCommand from apps.agentic.runtime.monitor import run_playbook_once +from apps.agentic.services.playbooks import recover_orphaned_playbook_runs from apps.common.worker_runner import SLEEP_WHEN_IDLE, add_worker_arguments, run_worker DEFAULT_INTERVAL_SECONDS = 3.0 +logger = logging.getLogger(__name__) class Command(BaseCommand): @@ -13,6 +17,9 @@ class Command(BaseCommand): add_worker_arguments(parser, interval_help="Seconds to sleep when no playbook run is pending.") def handle(self, *args, **options): + recovered = recover_orphaned_playbook_runs() + if recovered: + logger.warning("Recovered %d orphaned playbook run(s)", recovered) run_worker( self, options=options, diff --git a/backend/apps/agentic/runtime/base.py b/backend/apps/agentic/runtime/base.py index ddb04de..f7523fe 100644 --- a/backend/apps/agentic/runtime/base.py +++ b/backend/apps/agentic/runtime/base.py @@ -14,6 +14,7 @@ class BasePlaybook: NAME = "" DESC = "" TAGS = [] + RISK_LEVEL = "Low" PROMPT_SLUG = "" SCRIPT_PATH = None @@ -25,6 +26,13 @@ class BasePlaybook: def run(self): raise NotImplementedError + def add_run_message(self, message): + if self.playbook_run is None: + raise ValueError("Run messages require an active playbook run.") + from apps.agentic.services.playbooks import add_playbook_run_message + + add_playbook_run_message(self.playbook_run, message) + @classmethod def prompt_slug(cls): if cls.PROMPT_SLUG: diff --git a/backend/apps/agentic/runtime/monitor.py b/backend/apps/agentic/runtime/monitor.py index e803894..7ba3e9a 100644 --- a/backend/apps/agentic/runtime/monitor.py +++ b/backend/apps/agentic/runtime/monitor.py @@ -23,11 +23,11 @@ def run_playbook_once(*, scripts_dir=None): try: playbook_class = find_playbook_class(playbook_run.name, scripts_dir=scripts_dir) result = playbook_class(playbook_run=playbook_run).run() + mark_playbook_success(playbook_run, str(result)) except Exception as exc: mark_playbook_failed(playbook_run, exc) return True - mark_playbook_success(playbook_run, str(result)) return True diff --git a/backend/apps/agentic/services/custom.py b/backend/apps/agentic/services/custom.py index d95f9ef..bac9b05 100644 --- a/backend/apps/agentic/services/custom.py +++ b/backend/apps/agentic/services/custom.py @@ -38,6 +38,7 @@ def _playbook_record(definition): "name": definition.name, "description": getattr(definition.script_class, "DESC", ""), "tags": tags, + "risk_level": getattr(definition.script_class, "RISK_LEVEL", "Low"), "path": str(definition.path), "source": _source_for_path(definition.path), } diff --git a/backend/apps/agentic/services/playbooks.py b/backend/apps/agentic/services/playbooks.py index 0cc9749..2d85af3 100644 --- a/backend/apps/agentic/services/playbooks.py +++ b/backend/apps/agentic/services/playbooks.py @@ -1,17 +1,44 @@ import logging +import re import uuid from pathlib import Path from django.conf import settings from django.db import transaction +from django.db.models import Max +from django.utils import timezone from apps.agentic.runtime.base import BasePlaybook from apps.agentic.runtime.loader import discover_script_class, iter_overlaid_python_scripts +from apps.audit.context import suppress_audit from apps.inbox.notifications import notify_playbook_completion -from apps.playbooks.models import Playbook, PlaybookJobStatus +from apps.playbooks.models import Playbook, PlaybookJobStatus, PlaybookRunMessage logger = logging.getLogger(__name__) +PLAYBOOK_RISK_LEVELS = {"Low", "Medium", "High", "Critical"} +MAX_RUN_MESSAGE_LENGTH = 1000 +MAX_RUN_REMARK_LENGTH = 2000 +ORPHANED_RUN_REMARK = "Playbook worker stopped before completion." +_AUTHORIZATION_RE = re.compile(r"(?i)\bauthorization\b(\s*[:=]\s*)[^\r\n]+") +_SENSITIVE_ASSIGNMENT_RE = re.compile( + r"""(?ix) + \b(password|token|api[_-]?key|secret)\b + (\s*[:=]\s*) + ("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+) + """ +) + + +def _sanitize_visible_text(value, *, max_length): + text = str(value or "").strip() + text = _AUTHORIZATION_RE.sub(lambda match: f"authorization{match.group(1)}***", text) + text = _SENSITIVE_ASSIGNMENT_RE.sub( + lambda match: f"{match.group(1)}{match.group(2)}***", + text, + ) + return text[:max_length] + def default_playbook_scripts_dir(): return Path(settings.BASE_DIR) / "playbooks" @@ -65,6 +92,18 @@ def scan_playbook_definitions(*, scripts_dir=None, scripts_dirs=None): errors.append({"path": str(path), "error": "Failed to load playbook definition."}) continue if definition is not None: + risk_level = getattr(definition.script_class, "RISK_LEVEL", "Low") + if risk_level not in PLAYBOOK_RISK_LEVELS: + logger.error( + "Invalid playbook risk level: path=%s risk_level=%r", + path, + risk_level, + ) + errors.append({ + "path": str(path), + "error": f"RISK_LEVEL must be one of: {', '.join(sorted(PLAYBOOK_RISK_LEVELS))}.", + }) + continue definitions.append(definition) return definitions, errors @@ -81,6 +120,7 @@ def list_playbook_definitions(*, include_path=False, scripts_dir=None): "name": item.name, "description": getattr(item.script_class, "DESC", ""), "tags": _normalize_tags(getattr(item.script_class, "TAGS", [])), + "risk_level": item.script_class.RISK_LEVEL, } if include_path: data["path"] = str(item.path) @@ -119,7 +159,7 @@ def claim_pending_playbook_run(): Playbook.objects .select_for_update() .filter(job_status=PlaybookJobStatus.PENDING) - .order_by("created_at") + .order_by("created_at", "id") .first() ) if playbook is None: @@ -127,19 +167,55 @@ def claim_pending_playbook_run(): playbook.job_status = PlaybookJobStatus.RUNNING playbook.job_id = str(uuid.uuid4()) + playbook.started_at = timezone.now() + playbook.finished_at = None playbook.remark = "" - playbook.save(update_fields=["job_status", "job_id", "remark", "updated_at"]) + with suppress_audit(): + playbook.save(update_fields=[ + "job_status", + "job_id", + "started_at", + "finished_at", + "remark", + "updated_at", + ]) return playbook +@transaction.atomic +def add_playbook_run_message(playbook, message): + if not isinstance(message, str): + raise TypeError("Run message must be a string.") + sanitized = _sanitize_visible_text(message, max_length=MAX_RUN_MESSAGE_LENGTH) + if not sanitized: + raise ValueError("Run message must not be empty.") + + locked = Playbook.objects.select_for_update().get(pk=playbook.pk) + if locked.job_status != PlaybookJobStatus.RUNNING: + raise ValueError("Run messages can only be added while the playbook is Running.") + last_sequence = ( + PlaybookRunMessage.objects + .filter(playbook_run=locked) + .aggregate(value=Max("sequence"))["value"] + or 0 + ) + return PlaybookRunMessage.objects.create( + playbook_run=locked, + sequence=last_sequence + 1, + message=sanitized, + ) + + @transaction.atomic def mark_playbook_success(playbook, remark): locked = Playbook.objects.select_for_update().get(pk=playbook.pk) if locked.job_status != PlaybookJobStatus.RUNNING: raise ValueError(f"Playbook must be Running before success, got {locked.job_status}") locked.job_status = PlaybookJobStatus.SUCCESS - locked.remark = remark - locked.save(update_fields=["job_status", "remark", "updated_at"]) + locked.finished_at = timezone.now() + locked.remark = _sanitize_visible_text(remark, max_length=MAX_RUN_REMARK_LENGTH) + with suppress_audit(): + locked.save(update_fields=["job_status", "finished_at", "remark", "updated_at"]) notify_playbook_completion(locked) return locked @@ -150,8 +226,31 @@ def mark_playbook_failed(playbook, error): if locked.job_status != PlaybookJobStatus.RUNNING: raise ValueError(f"Playbook must be Running before failure, got {locked.job_status}") locked.job_status = PlaybookJobStatus.FAILED + locked.finished_at = timezone.now() logger.exception("Playbook execution failed", exc_info=error) locked.remark = "Playbook execution failed." - locked.save(update_fields=["job_status", "remark", "updated_at"]) + with suppress_audit(): + locked.save(update_fields=["job_status", "finished_at", "remark", "updated_at"]) notify_playbook_completion(locked) return locked + + +def recover_orphaned_playbook_runs(): + with transaction.atomic(): + orphaned_runs = list( + Playbook.objects + .select_for_update() + .select_related("user", "case") + .filter(job_status=PlaybookJobStatus.RUNNING) + ) + finished_at = timezone.now() + for playbook in orphaned_runs: + playbook.job_status = PlaybookJobStatus.FAILED + playbook.finished_at = finished_at + playbook.remark = ORPHANED_RUN_REMARK + with suppress_audit(): + playbook.save(update_fields=["job_status", "finished_at", "remark", "updated_at"]) + + for playbook in orphaned_runs: + notify_playbook_completion(playbook) + return len(orphaned_runs) diff --git a/backend/apps/audit/context.py b/backend/apps/audit/context.py index cdd4188..b586ce6 100644 --- a/backend/apps/audit/context.py +++ b/backend/apps/audit/context.py @@ -9,6 +9,10 @@ def get_current_actor(): return getattr(_state, "actor", None) +def audit_is_suppressed(): + return getattr(_state, "suppressed", False) + + @contextmanager def audit_actor(actor): previous = get_current_actor() @@ -17,3 +21,13 @@ def audit_actor(actor): yield finally: _state.actor = previous + + +@contextmanager +def suppress_audit(): + previous = audit_is_suppressed() + _state.suppressed = True + try: + yield + finally: + _state.suppressed = previous diff --git a/backend/apps/audit/signals.py b/backend/apps/audit/signals.py index 361a872..fef66b6 100644 --- a/backend/apps/audit/signals.py +++ b/backend/apps/audit/signals.py @@ -4,7 +4,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_de from django.dispatch import receiver from apps.common.models import BaseModel -from .context import get_current_actor +from .context import audit_is_suppressed, get_current_actor from .helpers import readable_label, write_relation_event from .models import AuditLog @@ -77,7 +77,7 @@ def write_delete_relation_events(sender, instance): @receiver(pre_save) def capture_previous_state(sender, instance, **kwargs): - if not audit_model(sender) or not instance.pk: + if audit_is_suppressed() or not audit_model(sender) or not instance.pk: instance._audit_previous = None return instance._audit_previous = sender.objects.filter(pk=instance.pk).first() @@ -85,7 +85,7 @@ def capture_previous_state(sender, instance, **kwargs): @receiver(pre_delete) def capture_delete_relation_parents(sender, instance, **kwargs): - if not audit_model(sender): + if audit_is_suppressed() or not audit_model(sender): return parents = {} for field_name in relation_fields(sender): @@ -96,7 +96,7 @@ def capture_delete_relation_parents(sender, instance, **kwargs): @receiver(post_save) def log_save(sender, instance, created, **kwargs): - if not audit_model(sender): + if audit_is_suppressed() or not audit_model(sender): return action = "create" if created else "update" previous = getattr(instance, "_audit_previous", None) @@ -114,7 +114,7 @@ def log_save(sender, instance, created, **kwargs): @receiver(post_delete) def log_delete(sender, instance, **kwargs): - if not audit_model(sender): + if audit_is_suppressed() or not audit_model(sender): return AuditLog.objects.create( content_type=ContentType.objects.get_for_model(sender), @@ -128,6 +128,8 @@ def log_delete(sender, instance, **kwargs): @receiver(m2m_changed) def log_many_to_many_change(sender, instance, action, reverse, model, pk_set, **kwargs): + if audit_is_suppressed(): + return if action not in {"post_add", "post_remove", "post_clear"}: return if not isinstance(instance, BaseModel): diff --git a/backend/apps/playbooks/migrations/0003_playbook_finished_at_playbook_started_at_and_more.py b/backend/apps/playbooks/migrations/0003_playbook_finished_at_playbook_started_at_and_more.py new file mode 100644 index 0000000..2759afc --- /dev/null +++ b/backend/apps/playbooks/migrations/0003_playbook_finished_at_playbook_started_at_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 6.0.7 on 2026-08-05 15:21 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('playbooks', '0002_playbook_playbook_created_job_idx'), + ] + + operations = [ + migrations.AddField( + model_name='playbook', + name='finished_at', + field=models.DateTimeField(blank=True, help_text='Execution finish time (执行结束时间)', null=True), + ), + migrations.AddField( + model_name='playbook', + name='started_at', + field=models.DateTimeField(blank=True, help_text='Execution start time (执行开始时间)', null=True), + ), + migrations.CreateModel( + name='PlaybookRunMessage', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('sequence', models.PositiveBigIntegerField()), + ('message', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('playbook_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='run_messages', to='playbooks.playbook')), + ], + options={ + 'db_table': 'playbook_run_messages', + 'ordering': ['sequence'], + 'indexes': [models.Index(fields=['playbook_run', 'sequence'], name='playbook_msg_run_seq_idx')], + 'constraints': [models.UniqueConstraint(fields=('playbook_run', 'sequence'), name='playbook_msg_run_seq_uniq')], + }, + ), + ] diff --git a/backend/apps/playbooks/models.py b/backend/apps/playbooks/models.py index 821cd31..3084718 100644 --- a/backend/apps/playbooks/models.py +++ b/backend/apps/playbooks/models.py @@ -1,3 +1,5 @@ +import uuid + from django.conf import settings from django.db import models @@ -27,6 +29,8 @@ class Playbook(BaseModel): help_text="Background job status (后台任务状态)", ) job_id = models.CharField(max_length=255, blank=True, default="", help_text="Background job ID (后台任务 ID)") + started_at = models.DateTimeField(null=True, blank=True, help_text="Execution start time (执行开始时间)") + finished_at = models.DateTimeField(null=True, blank=True, help_text="Execution finish time (执行结束时间)") remark = models.TextField(blank=True, default="", help_text="Execution remark (执行备注)") class Meta: @@ -41,3 +45,31 @@ class Playbook(BaseModel): def __str__(self): return self.name or str(self.id) + + +class PlaybookRunMessage(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + playbook_run = models.ForeignKey( + Playbook, + on_delete=models.CASCADE, + related_name="run_messages", + ) + sequence = models.PositiveBigIntegerField() + message = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "playbook_run_messages" + ordering = ["sequence"] + constraints = [ + models.UniqueConstraint( + fields=["playbook_run", "sequence"], + name="playbook_msg_run_seq_uniq", + ), + ] + indexes = [ + models.Index( + fields=["playbook_run", "sequence"], + name="playbook_msg_run_seq_idx", + ), + ] diff --git a/backend/apps/playbooks/serializers.py b/backend/apps/playbooks/serializers.py index 3b1390a..f68d3fe 100644 --- a/backend/apps/playbooks/serializers.py +++ b/backend/apps/playbooks/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers +from django.utils import timezone -from .models import Playbook +from .models import Playbook, PlaybookJobStatus, PlaybookRunMessage class PlaybookSerializer(serializers.ModelSerializer): @@ -8,8 +9,46 @@ class PlaybookSerializer(serializers.ModelSerializer): case_title = serializers.CharField(source="case.title", read_only=True) case_id = serializers.UUIDField(source="case.id", read_only=True) case_readable_id = serializers.CharField(source="case.case_id", read_only=True) + duration_seconds = serializers.SerializerMethodField() + + def get_duration_seconds(self, obj): + if obj.started_at is None: + return None + if obj.finished_at is not None: + end = obj.finished_at + elif obj.job_status == PlaybookJobStatus.RUNNING: + end = timezone.now() + else: + return None + return max(0, int((end - obj.started_at).total_seconds())) class Meta: model = Playbook - fields = "__all__" - read_only_fields = ("id", "playbook_id", "created_at", "updated_at") + fields = ( + "id", + "playbook_id", + "case", + "case_id", + "case_readable_id", + "case_title", + "name", + "user_input", + "user", + "user_username", + "job_status", + "job_id", + "started_at", + "finished_at", + "duration_seconds", + "remark", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class PlaybookRunMessageSerializer(serializers.ModelSerializer): + class Meta: + model = PlaybookRunMessage + fields = ("id", "sequence", "message", "created_at") + read_only_fields = fields diff --git a/backend/apps/playbooks/views.py b/backend/apps/playbooks/views.py index 2981340..70c4722 100644 --- a/backend/apps/playbooks/views.py +++ b/backend/apps/playbooks/views.py @@ -10,23 +10,22 @@ from rest_framework.response import Response from apps.accounts.permissions import IsBusinessWriterOrReadOnly from apps.agentic.services.playbooks import create_pending_playbook_run, list_playbook_definitions from apps.audit.context import audit_actor -from apps.audit.mixins import AuditActorMixin from apps.cases.models import Case from apps.common.advanced_filters import AdvancedFilterBackend from .models import Playbook -from .serializers import PlaybookSerializer +from .serializers import PlaybookRunMessageSerializer, PlaybookSerializer logger = logging.getLogger(__name__) -class PlaybookViewSet(AuditActorMixin, viewsets.ModelViewSet): +class PlaybookViewSet(viewsets.ReadOnlyModelViewSet): queryset = Playbook.objects.select_related("user", "case") serializer_class = PlaybookSerializer permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly] lookup_field = "id" filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend) search_fields = ("playbook_id", "name", "job_id", "user_input", "remark") - ordering_fields = ("created_at", "updated_at", "job_status") + ordering_fields = ("created_at", "updated_at", "job_status", "started_at", "finished_at") filterset_fields = ("job_status", "case__id") advanced_filter_fields = { "playbook_id": "text", @@ -37,12 +36,24 @@ class PlaybookViewSet(AuditActorMixin, viewsets.ModelViewSet): "remark": "text", "created_at": "date", "updated_at": "date", + "started_at": "date", + "finished_at": "date", } @action(detail=False, methods=["get"], url_path="definitions") def definitions(self, request): return Response(list_playbook_definitions()) + @action(detail=True, methods=["get"], url_path="messages") + def messages(self, request, id=None): + playbook = self.get_object() + queryset = playbook.run_messages.order_by("sequence") + page = self.paginate_queryset(queryset) + if page is not None: + serializer = PlaybookRunMessageSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + return Response(PlaybookRunMessageSerializer(queryset, many=True).data) + @action(detail=False, methods=["post"], url_path="run") def run(self, request): name = request.data.get("name") diff --git a/backend/playbooks/investigation.py b/backend/playbooks/investigation.py index 56c2be4..3f288ad 100644 --- a/backend/playbooks/investigation.py +++ b/backend/playbooks/investigation.py @@ -10,10 +10,13 @@ class Playbook(BasePlaybook): def run(self): if self.case is None: raise ValueError("Investigation playbook requires a linked case.") + self.add_run_message(f"Starting investigation for {self.case.case_id.upper()}.") + self.add_run_message("Generating the AI investigation report.") result = run_case_analysis( case=self.case, trigger="playbook", user_input=self.user_input, source=self.playbook_run, ) + self.add_run_message("Investigation report generated.") return f"Investigation completed: {result.report.digest}" diff --git a/docs/specs/v0.6.0/03-playbook-execution.md b/docs/specs/v0.6.0/03-playbook-execution.md index 039595f..7e4b0bf 100644 --- a/docs/specs/v0.6.0/03-playbook-execution.md +++ b/docs/specs/v0.6.0/03-playbook-execution.md @@ -4,9 +4,9 @@ Status: Confirmed ## 1. Purpose -保留低心智成本的 Python `run()` 编程模型,同时增加可持久化的结构化执行阶段、明确时间、取消、完整重试、崩溃恢复和只读运行历史。 +保留低心智成本的 Python `run()` 编程模型,同时增加可选的 UI 可见运行消息、明确时间、崩溃恢复和只读运行历史。 -该设计不是工作流引擎。Stage 是观测事件,不是独立调度、恢复或重试的 Step。 +该设计不是工作流引擎,也不要求开发者拆分或声明执行阶段。 ## 2. Authoring model @@ -20,26 +20,25 @@ class Playbook(BasePlaybook): RISK_LEVEL = "High" def run(self): - with self.stage("collect", "Collect endpoint context") as stage: - endpoints = collect_endpoints(self.case) - stage.summary = f"Collected {len(endpoints)} endpoint(s)." + self.add_run_message("Collecting endpoint context.") + endpoints = collect_endpoints(self.case) + self.add_run_message(f"Collected {len(endpoints)} endpoint(s).") for endpoint in endpoints: - with self.stage("contain", f"Contain {endpoint.hostname}") as stage: - contain(endpoint) - stage.summary = "Containment request accepted." + contain(endpoint) + self.add_run_message("Containment requests submitted.") return f"Contained {len(endpoints)} endpoint(s)." ``` 规则: - `run()` 仍是唯一执行入口。 -- Stage 完全可选;现有 v0.5.2 Playbook 无需修改即可运行。 -- Stage 不保存 Python 返回值或跨阶段输入。 -- Stage 不独立调度、不暂停、不恢复、不单步重试。 +- `add_run_message(message)` 完全可选;现有 v0.5.2 Playbook 无需修改即可运行。 +- Run Message 只是当前 Run 的 UI 可见文本,不保存 Python 返回值或执行上下文。 +- 平台不捕获 `print()` 或 Python `logging` 输出;服务器日志与 UI 可见消息保持分离。 - `run()` 未捕获异常导致整个 Run Failed。 -- 开发者可以在 Stage 内捕获可容忍错误,并显式写安全 summary。 +- 开发者可以捕获可容忍错误,并通过 `add_run_message()` 写入安全说明。 - Playbook 可直接使用 `httpx` 或厂商 SDK;平台不提供通用 Connector。 ## 3. Explicit exclusions @@ -47,8 +46,10 @@ class Playbook(BasePlaybook): - 可视化/表单式编排器。 - DAG、分支、并行 Step。 - 中途人工审批;点击 Run 即授权整个 Playbook。 -- Running hard cancel 或 cooperative cancel。 -- 单步 retry/resume。 +- Pending 或 Running 取消。 +- 专用 Retry、retry lineage 或单步 resume。 +- 结构化 Stage 或执行步骤。 +- 自动捕获 `print()` 或 Python `logging`。 - 源码、hash 或定义版本锁定。 - 结构化 input schema。 - HTTP/Webhook Connection profile。 @@ -72,12 +73,9 @@ class Playbook(BasePlaybook): 默认 Low。风险只用于 UI 展示,不改变权限、确认或执行流程。 -Run 不保存 DESC/TAGS/RISK_LEVEL 快照。历史页面按 name 解析当前定义: +Definition 选择界面展示当前扫描到的 metadata。Run 不保存 metadata 快照,历史列表和详情只展示 Run 已保存的 name,不解析当前定义。 -- 定义仍存在:展示当前 metadata。 -- 定义已删除或改名:只展示 Run 保存的 name,并标记 definition unavailable。 - -Pending/Retry 执行时始终加载当前最新 Python 代码。 +Pending 执行时始终加载当前最新 Python 代码。 ## 5. Run model @@ -87,12 +85,10 @@ Pending/Retry 执行时始终加载当前最新 Python 代码。 | Field | Type | Semantics | | --- | --- | --- | -| job_status | enum | Pending/Running/Success/Failed/Cancelled | +| job_status | enum | Pending/Running/Success/Failed | | job_id | string/UUID | 当前执行标识 | -| retry_of | nullable self FK | Failed Run 的重试来源 | | started_at | nullable datetime | claim 成功时间 | | finished_at | nullable datetime | terminal 时间 | -| cancelled_by | nullable FK User | Pending cancel actor | | remark | text | 终态安全摘要 | 保留: @@ -107,11 +103,10 @@ Pending/Retry 执行时始终加载当前最新 Python 代码。 | Current | Allowed next | | --- | --- | -| Pending | Running, Cancelled | +| Pending | Running | | Running | Success, Failed | | Success | none | -| Failed | none; Retry creates new Run | -| Cancelled | none | +| Failed | none | 不允许直接修改 job_status。所有状态变化通过 domain service。 @@ -120,64 +115,43 @@ Pending/Retry 执行时始终加载当前最新 Python 代码。 - Pending 创建时 started_at/finished_at 为空。 - Pending→Running 设置 started_at。 - Running→Success/Failed 设置 finished_at。 -- Pending→Cancelled 设置 finished_at,不设置 started_at。 - duration_seconds 由 started_at 和 finished_at 计算;Running 使用 now-started_at。 -## 6. Stage model +## 6. Run Message model -建议模型 `PlaybookStage`: +建议模型 `PlaybookRunMessage`: | Field | Type | Notes | | --- | --- | --- | | id | UUID | primary key | | playbook_run | FK | CASCADE at DB level, though Run API cannot delete | | sequence | positive bigint | per Run append order | -| key | string | developer supplied, may repeat | -| label | string | human-readable | -| status | enum | Running/Success/Failed | -| summary | text | explicit safe summary | -| error_type | string | sanitized exception class | -| error_message | text | sanitized length-limited message | -| started_at | datetime | context enter | -| finished_at | nullable datetime | context exit | -| duration_ms | nullable bigint | derived/persisted | +| message | text | UI-visible safe message | +| created_at | datetime | append time | Constraints/indexes: - unique `(playbook_run, sequence)`. - index `(playbook_run, sequence)`. -- index `(playbook_run, status)`. -- key/label 长度必须有限,但 Stage 数量不限。 +- message 经过长度限制。 -### Stage context behavior +### `add_run_message()` behavior -进入 `with self.stage(key, label)`: +调用 `self.add_run_message(message)`: -1. 原子分配下一个 sequence。 -2. 创建 Running Stage。 -3. 返回可设置 `summary` 的 context object。 +1. 只接受非空字符串。 +2. 对 message 执行敏感字段过滤和长度限制。 +3. 原子分配当前 Run 的下一个 sequence。 +4. 持久化消息,供 Run 详情 UI 按 sequence 展示。 -正常退出: - -1. 保存显式 summary。 -2. 设置 Success、finished_at、duration。 - -异常退出: - -1. 设置 Failed。 -2. 保存异常类型。 -3. 保存经过敏感字段过滤和长度限制的安全错误。 -4. 完整 traceback 只进入 Worker log。 -5. 原异常继续抛出,使 Run Failed。 - -Stage key/label 可以重复,支持循环动态生成。Stage 是扁平 sequence,不支持 parent。 +该方法返回 `None`,不提供 level、结构化字段、进度百分比或消息更新能力。开发者应记录少量有意义的执行信息,而不是逐条输出循环数据。 ### Output safety - 不自动 `str()` 或 JSON serialize 任意函数输出。 - 不自动保存 HTTP response、LLM output、SIEM records 或变量值。 -- summary 由自定义代码显式提供。 -- error_message 使用统一 sanitizer,至少屏蔽 password/token/api_key/secret/authorization 等值。 +- message 由自定义代码显式提供,并视为最终用户可见内容。 +- message 使用统一 sanitizer,至少屏蔽 password/token/api_key/secret/authorization 等值。 - API 不返回 traceback。 ## 7. Queue and Worker behavior @@ -188,14 +162,6 @@ Stage key/label 可以重复,支持循环动态生成。Stage 是扁平 sequen - 全局 FIFO,按 created_at/id claim。 - 不按 Case Severity 或用户优先级排序。 -### Per-Case concurrency - -同一 Case 最多一个 Running Run: - -- claim 时跳过已经存在 Running Run 的 Case。 -- 同 Case 的其他 Pending 保持队列。 -- 不同 Case 在未来多 Worker 实现中可并行,但 v0.6.0 不支持多 Worker。 - ### Duplicate launch Run endpoint 不提供 idempotency。重复请求可以创建多条 Pending Run,这是已接受行为。 @@ -205,45 +171,13 @@ Run endpoint 不提供 idempotency。重复请求可以创建多条 Pending Run Playbook Worker 使用 Worker Health 心跳。检测到前一实例丢失后: - 遗留 Running Run 标记 Failed。 -- 遗留 Running Stage 标记 Failed。 - remark 使用固定安全文本,说明 Worker stopped before completion。 - 不自动重置 Pending 或重跑。 -- 用户检查后手动 Retry。 +- 用户检查后可重新发起 Run。 实现可在 Worker 成功获取 singleton lease 后执行 orphan recovery。不得仅按运行时长把合法长任务判失败。 -## 8. Cancellation - -只有 Pending 可取消。 - -`POST /api/playbooks/{id}/cancel/` - -- Admin/User 可取消任意 Pending Run。 -- Viewer 403。 -- 非 Pending 返回 409。 -- 设置 Cancelled、finished_at、cancelled_by 和安全 remark。 -- 写 AuditLog。 -- 不发送 completion notification。 - -Running 无取消入口。Playbook 中每个外部调用必须自行设置超时。 - -## 9. Retry - -`POST /api/playbooks/{id}/retry/` - -- 仅 Failed Run。 -- Admin/User 可重试任意 Failed Run。 -- Viewer 403。 -- 创建新的 Pending Run。 -- `retry_of` 指向原 Run。 -- 复制 case、name、user_input。 -- 新 Run 的 user 是执行 Retry 的当前用户。 -- 使用当前 Case 数据和最新 Playbook 代码。 -- 原 Run/Stage 不修改。 -- 写 retry AuditLog,关联新旧 Run。 -- Retry 不受幂等保护;重复点击可能创建多个 Run。 - -## 10. Launch +## 8. Launch `POST /api/playbooks/run/` @@ -255,7 +189,7 @@ Running 无取消入口。Playbook 中每个外部调用必须自行设置超时 - 点击 Run 直接创建 Pending,不增加确认。 - risk level 只展示。 -## 11. API shape +## 9. API shape Playbook Run 资源改为 read-only: @@ -263,9 +197,7 @@ Playbook Run 资源改为 read-only: - GET retrieve。 - GET definitions。 - POST run。 -- POST cancel。 -- POST retry。 -- GET stages(detail action 或独立 nested endpoint)。 +- GET messages(detail action 或独立 nested endpoint)。 禁止: @@ -273,11 +205,11 @@ Playbook Run 资源改为 read-only: - PUT/PATCH。 - DELETE。 -Stage API: +Run Message API: - 只读。 - 必须按 sequence 分页。 -- 支持 status 筛选可选,但不得一次返回无限 Stage。 +- 不得一次返回无限消息。 ### Run response additions @@ -286,58 +218,40 @@ Stage API: "id": "...", "playbook_id": "playbook_000123", "job_status": "Running", - "retry_of": null, "started_at": "2026-07-30T08:00:00Z", "finished_at": null, - "duration_seconds": 42, - "current_stage": { - "sequence": 8, - "key": "contain", - "label": "Contain endpoint-7", - "status": "Running" - }, - "stage_count": 8, - "definition": { - "available": true, - "risk_level": "High", - "tags": ["EDR", "Response"] - } + "duration_seconds": 42 } ``` -## 12. Remark semantics +## 10. Remark semantics | Terminal state | Remark | | --- | --- | | Success | `str(run() return value)`,经长度限制和安全处理 | -| Failed | 固定安全摘要,可引用失败 Stage label/sequence | -| Cancelled | 固定文本并记录取消 actor | +| Failed | 固定安全摘要 | -过程日志不得拼接到 remark。原始异常只进服务器日志。 +Run Message 不得拼接到 remark。原始异常只进服务器日志。 -## 13. Notifications +## 11. Notifications 遵循发起用户现有 `notify_on_playbook_completion` 偏好: - Success 通知。 - Failed 通知。 -- Cancelled 不通知。 -- Stage 状态变化不通知。 -- Retry 新 Run 按新发起用户偏好处理。 +- Run Message 不通知。 -## 14. Audit +## 12. Audit 只记录用户动作: - launch -- cancel -- retry -Worker 自动状态变化不写全局 AuditLog,因为 Run/Stage 已是状态事实。 +Worker 自动状态变化不写全局 AuditLog,因为 Run 和 Run Message 已是状态事实。 -Audit metadata 不包含 user_input 全文、Stage summary 或任何 Secret。 +Audit metadata 不包含 user_input 全文、Run Message 或任何 Secret。 -## 15. Frontend +## 13. Frontend ### Definition selection @@ -347,42 +261,36 @@ Audit metadata 不包含 user_input 全文、Stage summary 或任何 Secret。 ### Run list/detail -- 状态、Case、发起人、时间、duration、retry relation。 -- Pending 显示 Cancel。 -- Failed 显示 Retry。 -- Run/Stage 无 Delete/Edit。 -- Stage 使用分页的扁平时间线或表格。 +- 状态、Case、发起人、时间和 duration。 +- Run/Run Message 无 Delete/Edit。 +- Run Message 在详情中按 sequence 分页展示。 - 页面不自动轮询、不使用 WebSocket;提供 Refresh。 -- definition 删除后显示 unavailable,而不是报页面错误。 -## 16. Migration +## 14. Migration - 现有四状态数据直接保留。 -- 新 Cancelled 只用于 v0.6.0 后记录。 - 已有 Success/Failed Run 的 started_at 可为空,不伪造历史时间。 - 旧 Running Run 在升级后由首次 Worker recovery 处理。 -- retry_of、timing、cancel actor 均 nullable。 +- started_at、finished_at 均 nullable。 -## 17. Acceptance criteria +## 15. Acceptance criteria 1. v0.5.2 旧 Playbook 不修改即可运行。 -2. 可选 Stage 正确保存动态重复 key 和 sequence。 -3. Stage 异常导致 Stage/Run Failed,API 不泄露 traceback。 -4. Pending 可取消,Running 不可取消。 -5. Failed Retry 创建新 Run并保留原历史。 -6. Run/Stage 所有普通 mutation/delete 被拒绝。 -7. 同一 Case 不同时 Running 两个 Run。 -8. FIFO claim 可预测。 -9. Worker 崩溃后遗留 Running 标记 Failed且不自动重跑。 -10. Closed Case 可运行,Case Relationship 不影响运行。 -11. Success/Failed 通知符合用户偏好。 -12. Stage 数量大时 API 正确分页。 +2. 可选 `add_run_message()` 按 sequence 保存并可在 UI 查询。 +3. Run 异常导致 Run Failed,API 不泄露 traceback。 +4. Run/Run Message 所有普通 mutation/delete 被拒绝。 +5. FIFO claim 可预测。 +6. Worker 崩溃后遗留 Running 标记 Failed且不自动重跑。 +7. Closed Case 可运行,Case Relationship 不影响运行。 +8. Success/Failed 通知符合用户偏好。 +9. Run Message 数量大时 API 正确分页。 -## 18. Known tradeoffs +## 16. Known tradeoffs - 最新代码执行使 Pending Run 语义可能在排队期间变化。 -- 不保存 metadata 快照,历史 risk/tags 会随定义变化。 -- 重复 launch/retry 可产生重复外部副作用。 -- Running 不可取消。 -- 动态无限 Stage 可能产生大量数据,开发者需自律;平台只通过分页保护读取。 +- 历史 Run 不展示 description、tags 或 risk level。 +- 重复 launch 可产生重复外部副作用。 +- Pending 和 Running 均不可取消。 +- Failed Run 没有专用 Retry;用户需要重新发起。 +- 频繁调用 `add_run_message()` 可能产生大量数据,开发者需自律;平台只通过分页保护读取。 - 直接 httpx 调用的重试、幂等和 Secret 安全由自定义代码负责。 diff --git a/frontend/src/components/CasePlaybookRunModal.tsx b/frontend/src/components/CasePlaybookRunModal.tsx index 62a19e3..92b9791 100644 --- a/frontend/src/components/CasePlaybookRunModal.tsx +++ b/frontend/src/components/CasePlaybookRunModal.tsx @@ -6,6 +6,7 @@ import type {ColumnsType} from 'antd/es/table' import client from '../api/client' import OverflowTags from './OverflowTags' import {comfortableTagProps} from '../utils/tagStyles' +import {severityTag} from '../utils/recordDisplay' type RecordRow = Record @@ -13,6 +14,7 @@ interface PlaybookDefinition { name: string description: string tags: string[] + risk_level: 'Low' | 'Medium' | 'High' | 'Critical' } interface CasePlaybookActionProps { @@ -156,11 +158,23 @@ function CasePlaybookRunModal({ open, caseId, onClose, onSubmitted }: CasePlaybo return [ definition.name, definition.description, + definition.risk_level, ...normalizeTags(definition.tags), ].some((value) => value.toLowerCase().includes(keyword)) }, render: (name: string) => {name}, }, + { + title: 'Risk', + dataIndex: 'risk_level', + width: 140, + filters: ['Low', 'Medium', 'High', 'Critical'].map((riskLevel) => ({ + text: riskLevel, + value: riskLevel, + })), + onFilter: (filterValue, definition) => definition.risk_level === filterValue, + render: (riskLevel: string) => severityTag(riskLevel), + }, { title: 'Tags', dataIndex: 'tags', @@ -256,7 +270,10 @@ function CasePlaybookRunModal({ open, caseId, onClose, onSubmitted }: CasePlaybo <>
{selectedDefinition.name} - + + {severityTag(selectedDefinition.risk_level)} + +
{selectedDefinition.description || 'No description.'} diff --git a/frontend/src/components/PlaybookBasicView.tsx b/frontend/src/components/PlaybookBasicView.tsx index 2565b36..46b0c05 100644 --- a/frontend/src/components/PlaybookBasicView.tsx +++ b/frontend/src/components/PlaybookBasicView.tsx @@ -2,10 +2,10 @@ import type {ReactNode} from 'react' import type {DescriptionsProps} from 'antd' import {Button, Descriptions} from 'antd' import {DescriptionValue} from './DescriptionValue' -import DetailSectionDivider from './DetailSectionDivider' import {descriptionStyles} from './descriptionValueStyles' -import {emptyValue} from '../utils/recordDisplay' +import {emptyValue, formatDateTime, formatDurationSeconds} from '../utils/recordDisplay' import {monoTextStyle} from '../utils/typography' +import PlaybookRunMessagesView from './PlaybookRunMessagesView' type RecordRow = Record @@ -22,19 +22,16 @@ const upperStringValue = (record: RecordRow, key: string) => { return displayValue === '—' ? displayValue : displayValue.toUpperCase() } -function Section({ title, items, showTitle = true }: { title?: string; items: DescriptionsProps['items']; showTitle?: boolean }) { +function SummarySection({items}: {items: DescriptionsProps['items']}) { return ( -
- {showTitle && title && } - -
+ ) } @@ -57,6 +54,7 @@ function blockItem(key: string, label: string, children: string) { } export default function PlaybookBasicView({ record, onOpenResource, renderStatus }: PlaybookBasicViewProps) { + const playbookRunId = String(value(record, 'id') || '') const caseRowId = value(record, 'case_id') as string | number | null | undefined const caseReadableId = upperStringValue(record, 'case_readable_id') const caseLabel = caseReadableId === '—' @@ -82,6 +80,9 @@ export default function PlaybookBasicView({ record, onOpenResource, renderStatus item('user', 'User', stringValue(record, 'user_username')), item('name', 'Name', stringValue(record, 'name')), { key: 'job-id', label: 'Job ID', children: {stringValue(record, 'job_id')} }, + item('started-at', 'Started Time', formatDateTime(String(value(record, 'started_at') || ''))), + item('finished-at', 'Finished Time', formatDateTime(String(value(record, 'finished_at') || ''))), + item('duration', 'Duration', formatDurationSeconds(value(record, 'duration_seconds'))), ] const inputItems: DescriptionsProps['items'] = [ @@ -91,8 +92,28 @@ export default function PlaybookBasicView({ record, onOpenResource, renderStatus return (
-
-
+ +
+
+ +
+
+ +
+
) } diff --git a/frontend/src/components/PlaybookRunMessagesView.tsx b/frontend/src/components/PlaybookRunMessagesView.tsx new file mode 100644 index 0000000..b452d73 --- /dev/null +++ b/frontend/src/components/PlaybookRunMessagesView.tsx @@ -0,0 +1,121 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {Alert, Empty, Table} from 'antd' +import type {ColumnsType, TablePaginationConfig} from 'antd/es/table' +import client from '../api/client' +import {formatDateTime} from '../utils/recordDisplay' + +interface PlaybookRunMessage { + id: string + sequence: number + message: string + created_at: string +} + +interface PaginatedMessages { + count: number + results: PlaybookRunMessage[] +} + +function errorMessage(error: unknown) { + const detail = (error as {response?: {data?: {detail?: unknown}}}).response?.data?.detail + return typeof detail === 'string' ? detail : 'Failed to load run messages' +} + +export default function PlaybookRunMessagesView({ + playbookRunId, + refreshToken, +}: { + playbookRunId: string + refreshToken?: unknown +}) { + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [total, setTotal] = useState(0) + const requestIdRef = useRef(0) + + const loadMessages = useCallback(async (nextPage: number, nextPageSize: number) => { + void refreshToken + if (!playbookRunId) return + const requestId = requestIdRef.current + 1 + requestIdRef.current = requestId + setLoading(true) + setError('') + try { + const {data} = await client.get( + `/playbooks/${encodeURIComponent(playbookRunId)}/messages/`, + {params: {page: nextPage, page_size: nextPageSize}}, + ) + if (requestId !== requestIdRef.current) return + setMessages(data.results) + setTotal(data.count) + } catch (loadError) { + if (requestId !== requestIdRef.current) return + setMessages([]) + setTotal(0) + setError(errorMessage(loadError)) + } finally { + if (requestId === requestIdRef.current) setLoading(false) + } + }, [playbookRunId, refreshToken]) + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadMessages(page, pageSize) + return () => { + requestIdRef.current += 1 + } + }, [loadMessages, page, pageSize]) + + const columns = useMemo>(() => [ + { + title: '#', + dataIndex: 'sequence', + width: 88, + }, + { + title: 'Time', + dataIndex: 'created_at', + width: 220, + render: (value: string) => formatDateTime(value), + }, + { + title: 'Message', + dataIndex: 'message', + render: (value: string) => ( + {value} + ), + }, + ], []) + + const handleTableChange = (pagination: TablePaginationConfig) => { + setPage(pagination.current || 1) + setPageSize(pagination.pageSize || 20) + } + + return ( +
+ {error && } + + rowKey="id" + size="small" + columns={columns} + dataSource={messages} + loading={loading} + onChange={handleTableChange} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (count) => `${count} message${count === 1 ? '' : 's'}`, + }} + locale={{emptyText: loading ? : }} + scroll={{y: 320}} + /> +
+ ) +} diff --git a/frontend/src/components/ResourceListPage.tsx b/frontend/src/components/ResourceListPage.tsx index 8a0de71..45d794f 100644 --- a/frontend/src/components/ResourceListPage.tsx +++ b/frontend/src/components/ResourceListPage.tsx @@ -70,6 +70,7 @@ export default function ResourceListPage({ resourceKey, actions }: ResourceListP }} onOpenResource={openRelatedDetail} refreshToken={tableRefreshToken} + readOnly={config.readOnly} dense fillParent /> diff --git a/frontend/src/config/resources.tsx b/frontend/src/config/resources.tsx index 76389b0..d7e3681 100644 --- a/frontend/src/config/resources.tsx +++ b/frontend/src/config/resources.tsx @@ -37,6 +37,7 @@ import { emptyValue, emptyValueNode, formatDateTime, + formatDurationSeconds, knowledgeSourceTag, productCategoryTag, severityTag, @@ -842,6 +843,7 @@ export const resourceConfigs: Record> = { icon: , endpoint: '/playbooks/', rowKey: 'id', + readOnly: true, searchPlaceholder: 'Playbook ID, Name, Job ID, Remark', filters: [{key: 'job_status', label: 'Status', valueType: 'select', width: L132}], advancedFilters: [ @@ -853,6 +855,8 @@ export const resourceConfigs: Record> = { field('remark', 'Remark', 'text'), field('created_at', 'Created Time', 'date'), field('updated_at', 'Updated Time', 'date'), + field('started_at', 'Started Time', 'date'), + field('finished_at', 'Finished Time', 'date'), ], columns: [ column('playbook_id', 'Playbook ID', L160, {required: true, defaultVisible: true, fixed: 'left', openRecord: true, uppercase: true}), @@ -868,6 +872,9 @@ export const resourceConfigs: Record> = { }), column('user_username', 'User', L160, {defaultVisible: true}), column('job_id', 'Job ID', L240, {defaultVisible: true}), + column('started_at', 'Started Time', L160, {defaultVisible: true, sorter: true, render: date('started_at')}), + column('finished_at', 'Finished Time', L160, {defaultVisible: true, sorter: true, render: date('finished_at')}), + column('duration_seconds', 'Duration', L132, {defaultVisible: true, render: (v) => formatDurationSeconds(v)}), column('created_at', 'Created Time', L160, {defaultVisible: true, sorter: true, render: date('created_at')}), column('updated_at', 'Updated Time', L160, {defaultVisible: true, sorter: true, render: date('updated_at')}), column('user_input', 'User Input', L400), diff --git a/frontend/src/pages/CustomDefinitions.tsx b/frontend/src/pages/CustomDefinitions.tsx index 33afb84..5900223 100644 --- a/frontend/src/pages/CustomDefinitions.tsx +++ b/frontend/src/pages/CustomDefinitions.tsx @@ -10,6 +10,7 @@ import IconTabLabel from '../components/IconTabLabel' import OverflowTags from '../components/OverflowTags' import CustomVariablesSettings from './CustomVariablesSettings' import {comfortableTagProps} from '../utils/tagStyles' +import {severityTag} from '../utils/recordDisplay' type SourceType = 'official' | 'custom' type SourceFilter = 'all' | SourceType @@ -59,6 +60,7 @@ interface PlaybookDefinition { name: string description: string tags: string[] + risk_level: 'Low' | 'Medium' | 'High' | 'Critical' source: SourceType path: string } @@ -426,6 +428,7 @@ function PlaybooksTab() { filterBySource(result?.items || [], source).filter((item) => includesSearch([ item.name, item.description, + item.risk_level, item.path, ...item.tags, ], search)) @@ -434,6 +437,7 @@ function PlaybooksTab() { const columns = useMemo>(() => [ { title: 'Playbook', dataIndex: 'name', width: 260, render: (name: string) => {name} }, { title: 'Source', dataIndex: 'source', width: 110, render: (value: SourceType) => }, + { title: 'Risk', dataIndex: 'risk_level', width: 110, render: (value: string) => severityTag(value) }, { title: 'Tags', dataIndex: 'tags', @@ -472,6 +476,7 @@ function PlaybooksTab() { + {severityTag(selected.risk_level)} {(selected.tags || []).map((tag) => {tag})} {selected.description || 'No description.'} diff --git a/frontend/src/types/records.ts b/frontend/src/types/records.ts index 0dd1d8b..131af52 100644 --- a/frontend/src/types/records.ts +++ b/frontend/src/types/records.ts @@ -176,6 +176,7 @@ export interface ResourceConfig> { filters: ResourceFilterConfig[] advancedFilters?: AdvancedFilterFieldConfig[] editableFields?: EditableFieldConfig[] + readOnly?: boolean basicView?: (record: RecordType, options?: { onOpenResource?: (resourceKey: string, rowId: string | number, options?: OpenResourceOptions) => void onChanged?: () => void