mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
feat(playbooks): improve execution visibility
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+1
-1
Submodule asp-marketplace updated: 41982af7da...164bdcdd41
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
+41
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 安全由自定义代码负责。
|
||||
|
||||
@@ -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<string, unknown>
|
||||
|
||||
@@ -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) => <Typography.Text strong>{name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
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
|
||||
<>
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>{selectedDefinition.name}</Typography.Title>
|
||||
<PlaybookTags tags={selectedDefinition.tags} />
|
||||
<Space wrap>
|
||||
{severityTag(selectedDefinition.risk_level)}
|
||||
<PlaybookTags tags={selectedDefinition.tags} />
|
||||
</Space>
|
||||
</div>
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||
{selectedDefinition.description || 'No description.'}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ marginTop: showTitle ? 16 : 0 }}>
|
||||
{showTitle && title && <DetailSectionDivider title={title} />}
|
||||
<Descriptions
|
||||
size="small"
|
||||
layout="vertical"
|
||||
colon={false}
|
||||
column={4}
|
||||
items={items}
|
||||
styles={descriptionStyles}
|
||||
/>
|
||||
</div>
|
||||
<Descriptions
|
||||
size="small"
|
||||
layout="vertical"
|
||||
colon={false}
|
||||
column={4}
|
||||
items={items}
|
||||
styles={descriptionStyles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: <DescriptionValue><span style={monoTextStyle}>{stringValue(record, 'job_id')}</span></DescriptionValue> },
|
||||
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 (
|
||||
<div style={{ padding: '20px 20px 16px', overflow: 'auto', height: '100%', boxSizing: 'border-box' }}>
|
||||
<Section showTitle={false} items={summaryItems} />
|
||||
<Section title="Input & Result" items={inputItems} />
|
||||
<SummarySection items={summaryItems} />
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(320px, 2fr) minmax(0, 3fr)',
|
||||
gap: 24,
|
||||
alignItems: 'start',
|
||||
marginTop: 24,
|
||||
}}>
|
||||
<div style={{minWidth: 0}}>
|
||||
<Descriptions
|
||||
size="small"
|
||||
layout="vertical"
|
||||
colon={false}
|
||||
column={4}
|
||||
items={inputItems}
|
||||
styles={descriptionStyles}
|
||||
/>
|
||||
</div>
|
||||
<div style={{minWidth: 0}}>
|
||||
<PlaybookRunMessagesView playbookRunId={playbookRunId} refreshToken={record} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<PlaybookRunMessage[]>([])
|
||||
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<PaginatedMessages>(
|
||||
`/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<ColumnsType<PlaybookRunMessage>>(() => [
|
||||
{
|
||||
title: '#',
|
||||
dataIndex: 'sequence',
|
||||
width: 88,
|
||||
},
|
||||
{
|
||||
title: 'Time',
|
||||
dataIndex: 'created_at',
|
||||
width: 220,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: 'Message',
|
||||
dataIndex: 'message',
|
||||
render: (value: string) => (
|
||||
<span style={{whiteSpace: 'pre-wrap', overflowWrap: 'anywhere'}}>{value}</span>
|
||||
),
|
||||
},
|
||||
], [])
|
||||
|
||||
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||
setPage(pagination.current || 1)
|
||||
setPageSize(pagination.pageSize || 20)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{minWidth: 0}}>
|
||||
{error && <Alert type="error" showIcon title={error} style={{marginBottom: 12}}/>}
|
||||
<Table<PlaybookRunMessage>
|
||||
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 ? <span/> : <Empty description="No run messages"/>}}
|
||||
scroll={{y: 320}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export default function ResourceListPage({ resourceKey, actions }: ResourceListP
|
||||
}}
|
||||
onOpenResource={openRelatedDetail}
|
||||
refreshToken={tableRefreshToken}
|
||||
readOnly={config.readOnly}
|
||||
dense
|
||||
fillParent
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
emptyValue,
|
||||
emptyValueNode,
|
||||
formatDateTime,
|
||||
formatDurationSeconds,
|
||||
knowledgeSourceTag,
|
||||
productCategoryTag,
|
||||
severityTag,
|
||||
@@ -842,6 +843,7 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
|
||||
icon: <BrainCircuit {...lucideIconProps}/>,
|
||||
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<string, ResourceConfig<RecordRow>> = {
|
||||
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<string, ResourceConfig<RecordRow>> = {
|
||||
}),
|
||||
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),
|
||||
|
||||
@@ -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<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: 'Risk', dataIndex: 'risk_level', width: 110, render: (value: string) => severityTag(value) },
|
||||
{
|
||||
title: 'Tags',
|
||||
dataIndex: 'tags',
|
||||
@@ -472,6 +476,7 @@ function PlaybooksTab() {
|
||||
<Flex vertical gap={16} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<SourceTag source={selected.source} />
|
||||
{severityTag(selected.risk_level)}
|
||||
{(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>
|
||||
|
||||
@@ -176,6 +176,7 @@ export interface ResourceConfig<RecordType = Record<string, unknown>> {
|
||||
filters: ResourceFilterConfig[]
|
||||
advancedFilters?: AdvancedFilterFieldConfig[]
|
||||
editableFields?: EditableFieldConfig[]
|
||||
readOnly?: boolean
|
||||
basicView?: (record: RecordType, options?: {
|
||||
onOpenResource?: (resourceKey: string, rowId: string | number, options?: OpenResourceOptions) => void
|
||||
onChanged?: () => void
|
||||
|
||||
Reference in New Issue
Block a user