mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
Merge remote-tracking branch 'origin/master'
# Conflicts: # docs/specs/v0.6.0/00-release-scope.md # docs/specs/v0.6.0/05-worker-health.md # docs/specs/v0.6.0/README.md # docs/specs/v0.6.0/TODO-remaining-domains.md
This commit is contained in:
+1
-1
Submodule asp-doc updated: c87ef70df2...528ff15c2e
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 6.0.7 on 2026-08-03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_string_values(apps, schema_editor):
|
||||
CustomVariable = apps.get_model("settings", "CustomVariable")
|
||||
for variable in CustomVariable.objects.only("id", "value").iterator():
|
||||
variable.typed_value = variable.value
|
||||
variable.save(update_fields=["typed_value"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("settings", "0005_customvariable"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customvariable",
|
||||
name="value_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("string", "String"),
|
||||
("integer", "Integer"),
|
||||
("float", "Float"),
|
||||
("boolean", "Boolean"),
|
||||
("list", "List"),
|
||||
("dictionary", "Dictionary"),
|
||||
],
|
||||
default="string",
|
||||
max_length=16,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="customvariable",
|
||||
name="typed_value",
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
migrations.RunPython(copy_string_values),
|
||||
migrations.RemoveField(
|
||||
model_name="customvariable",
|
||||
name="value",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="customvariable",
|
||||
old_name="typed_value",
|
||||
new_name="value",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="customvariable",
|
||||
name="value",
|
||||
field=models.JSONField(),
|
||||
),
|
||||
]
|
||||
@@ -160,6 +160,14 @@ class RuntimeConfig(models.Model):
|
||||
|
||||
|
||||
class CustomVariable(models.Model):
|
||||
class ValueType(models.TextChoices):
|
||||
STRING = "string", "String"
|
||||
INTEGER = "integer", "Integer"
|
||||
FLOAT = "float", "Float"
|
||||
BOOLEAN = "boolean", "Boolean"
|
||||
LIST = "list", "List"
|
||||
DICTIONARY = "dictionary", "Dictionary"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
key = models.CharField(
|
||||
max_length=128,
|
||||
@@ -171,7 +179,8 @@ class CustomVariable(models.Model):
|
||||
)
|
||||
],
|
||||
)
|
||||
value = models.TextField()
|
||||
value_type = models.CharField(max_length=16, choices=ValueType.choices)
|
||||
value = models.JSONField()
|
||||
is_secret = models.BooleanField(default=False)
|
||||
description = models.TextField(blank=True, default="")
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import json
|
||||
import math
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import (
|
||||
@@ -13,9 +16,94 @@ from .models import (
|
||||
|
||||
|
||||
MAX_CUSTOM_VARIABLE_VALUE_BYTES = 65_536
|
||||
MAX_CUSTOM_VARIABLE_DEPTH = 20
|
||||
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
||||
|
||||
|
||||
def _validate_structured_custom_variable(value, depth=0):
|
||||
if isinstance(value, list):
|
||||
if depth > MAX_CUSTOM_VARIABLE_DEPTH:
|
||||
raise serializers.ValidationError(
|
||||
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_DEPTH} levels of nesting."
|
||||
)
|
||||
for item in value:
|
||||
_validate_structured_custom_variable(item, depth + 1)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
if depth > MAX_CUSTOM_VARIABLE_DEPTH:
|
||||
raise serializers.ValidationError(
|
||||
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_DEPTH} levels of nesting."
|
||||
)
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise serializers.ValidationError("Dictionary keys must be strings.")
|
||||
for item in value.values():
|
||||
_validate_structured_custom_variable(item, depth + 1)
|
||||
return
|
||||
if value is None or type(value) in {str, int, float, bool}:
|
||||
return
|
||||
raise serializers.ValidationError("Value must contain valid JSON values.")
|
||||
|
||||
|
||||
def _validate_custom_variable_value(value_type, value):
|
||||
if value_type == CustomVariable.ValueType.STRING:
|
||||
if not isinstance(value, str):
|
||||
raise serializers.ValidationError("Value must be a string.")
|
||||
if value == "":
|
||||
raise serializers.ValidationError("Value cannot be empty.")
|
||||
encoded_value = value.encode("utf-8")
|
||||
elif value_type == CustomVariable.ValueType.INTEGER:
|
||||
if type(value) is not int:
|
||||
raise serializers.ValidationError("Value must be an integer.")
|
||||
if not -MAX_SAFE_INTEGER <= value <= MAX_SAFE_INTEGER:
|
||||
raise serializers.ValidationError(
|
||||
f"Value must be between {-MAX_SAFE_INTEGER:,} and {MAX_SAFE_INTEGER:,}."
|
||||
)
|
||||
encoded_value = json.dumps(value).encode("utf-8")
|
||||
elif value_type == CustomVariable.ValueType.FLOAT:
|
||||
if type(value) not in {int, float}:
|
||||
raise serializers.ValidationError("Value must be a number.")
|
||||
value = float(value)
|
||||
if not math.isfinite(value):
|
||||
raise serializers.ValidationError("Value must be a finite number.")
|
||||
encoded_value = json.dumps(value).encode("utf-8")
|
||||
elif value_type == CustomVariable.ValueType.BOOLEAN:
|
||||
if type(value) is not bool:
|
||||
raise serializers.ValidationError("Value must be a boolean.")
|
||||
encoded_value = json.dumps(value).encode("utf-8")
|
||||
elif value_type == CustomVariable.ValueType.LIST:
|
||||
if not isinstance(value, list):
|
||||
raise serializers.ValidationError("Value must be a list.")
|
||||
encoded_value = _encode_structured_custom_variable(value)
|
||||
elif value_type == CustomVariable.ValueType.DICTIONARY:
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Value must be a dictionary.")
|
||||
encoded_value = _encode_structured_custom_variable(value)
|
||||
else:
|
||||
raise serializers.ValidationError("Unsupported value type.")
|
||||
|
||||
if len(encoded_value) > MAX_CUSTOM_VARIABLE_VALUE_BYTES:
|
||||
raise serializers.ValidationError(
|
||||
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_VALUE_BYTES:,} UTF-8 bytes."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _encode_structured_custom_variable(value):
|
||||
_validate_structured_custom_variable(value, depth=1)
|
||||
try:
|
||||
serialized = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise serializers.ValidationError("Value must contain valid JSON values.") from exc
|
||||
return serialized.encode("utf-8")
|
||||
|
||||
|
||||
class CustomVariableSerializer(serializers.ModelSerializer):
|
||||
value = serializers.JSONField(required=False)
|
||||
value_configured = serializers.SerializerMethodField()
|
||||
confirm_secret_exposure = serializers.BooleanField(write_only=True, required=False, default=False)
|
||||
|
||||
@@ -24,6 +112,7 @@ class CustomVariableSerializer(serializers.ModelSerializer):
|
||||
fields = (
|
||||
"id",
|
||||
"key",
|
||||
"value_type",
|
||||
"value",
|
||||
"value_configured",
|
||||
"is_secret",
|
||||
@@ -35,50 +124,65 @@ class CustomVariableSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
read_only_fields = ("id", "value_configured", "created_at", "updated_at")
|
||||
extra_kwargs = {
|
||||
"value": {"required": False, "trim_whitespace": False, "allow_blank": False},
|
||||
"description": {"required": False, "allow_blank": True},
|
||||
}
|
||||
|
||||
def get_value_configured(self, obj):
|
||||
return bool(obj.value)
|
||||
return obj.value is not None
|
||||
|
||||
def validate_key(self, value):
|
||||
if self.instance is not None and value != self.instance.key:
|
||||
raise serializers.ValidationError("Key cannot be changed.")
|
||||
return value
|
||||
|
||||
def validate_value(self, value):
|
||||
if value == "":
|
||||
raise serializers.ValidationError("Value cannot be empty.")
|
||||
if len(value.encode("utf-8")) > MAX_CUSTOM_VARIABLE_VALUE_BYTES:
|
||||
raise serializers.ValidationError(
|
||||
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_VALUE_BYTES:,} UTF-8 bytes."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
confirmation = attrs.pop("confirm_secret_exposure", False)
|
||||
|
||||
if self.instance is None:
|
||||
if "value_type" not in attrs:
|
||||
raise serializers.ValidationError({"value_type": "Value type is required."})
|
||||
if "value" not in attrs:
|
||||
raise serializers.ValidationError({"value": "Value is required."})
|
||||
return attrs
|
||||
elif attrs.get("value_type", self.instance.value_type) != self.instance.value_type:
|
||||
if "value" not in attrs:
|
||||
raise serializers.ValidationError({
|
||||
"value": "Value is required when changing the value type."
|
||||
})
|
||||
|
||||
if not self.partial and "value" not in attrs and not self.instance.is_secret:
|
||||
raise serializers.ValidationError({"value": "Value is required."})
|
||||
|
||||
next_is_secret = attrs.get("is_secret", self.instance.is_secret)
|
||||
if self.instance.is_secret and not next_is_secret and not confirmation:
|
||||
value_type = attrs.get(
|
||||
"value_type",
|
||||
self.instance.value_type if self.instance else None,
|
||||
)
|
||||
next_is_secret = attrs.get(
|
||||
"is_secret",
|
||||
self.instance.is_secret if self.instance else False,
|
||||
)
|
||||
if next_is_secret and value_type != CustomVariable.ValueType.STRING:
|
||||
raise serializers.ValidationError({
|
||||
"is_secret": "Only String variables can be secret."
|
||||
})
|
||||
if (
|
||||
self.instance is not None
|
||||
and self.instance.is_secret
|
||||
and not next_is_secret
|
||||
and not confirmation
|
||||
):
|
||||
raise serializers.ValidationError({
|
||||
"confirm_secret_exposure": "Confirm that this secret value may be exposed."
|
||||
})
|
||||
|
||||
if "value" in attrs:
|
||||
try:
|
||||
attrs["value"] = _validate_custom_variable_value(value_type, attrs["value"])
|
||||
except serializers.ValidationError as exc:
|
||||
raise serializers.ValidationError({"value": exc.detail}) from exc
|
||||
return attrs
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
if instance.is_secret:
|
||||
data["value"] = ""
|
||||
data["value"] = None
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,14 @@ RUNTIME_AUDIT_FIELDS = (
|
||||
"stream_maxlen",
|
||||
"dashboard_refresh_interval_seconds",
|
||||
)
|
||||
CUSTOM_VARIABLE_AUDIT_FIELDS = ("key", "value", "is_secret", "description", "enabled")
|
||||
CUSTOM_VARIABLE_AUDIT_FIELDS = (
|
||||
"key",
|
||||
"value_type",
|
||||
"value",
|
||||
"is_secret",
|
||||
"description",
|
||||
"enabled",
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(instance, fields):
|
||||
@@ -211,11 +218,19 @@ class CustomVariableViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdmin]
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
|
||||
search_fields = ("key", "description")
|
||||
filterset_fields = ("is_secret", "enabled")
|
||||
ordering_fields = ("key", "is_secret", "enabled", "created_at", "updated_at")
|
||||
filterset_fields = ("value_type", "is_secret", "enabled")
|
||||
ordering_fields = (
|
||||
"key",
|
||||
"value_type",
|
||||
"is_secret",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
advanced_filter_fields = {
|
||||
"key": "text",
|
||||
"description": "text",
|
||||
"value_type": "select",
|
||||
"is_secret": "select",
|
||||
"enabled": "select",
|
||||
"created_at": "date",
|
||||
|
||||
@@ -85,12 +85,10 @@ v0.6.0 保持三个固定角色:
|
||||
3. Playbook 执行可观测性和控制。
|
||||
4. Custom Variables。
|
||||
5. Worker Health。
|
||||
6. SLA 管理,待详细讨论。
|
||||
7. AI 质量评估,待详细讨论。
|
||||
8. 抑制规则,待详细讨论。
|
||||
9. Operations 页面整合,待详细讨论。
|
||||
6. SLA 管理。
|
||||
7. AI 质量评估。
|
||||
|
||||
SLA、AI 质量评估和抑制规则都是 v0.6.0 正式发布的阻断项,但必须限制为最小可用闭环。
|
||||
SLA 和 AI 质量评估是 v0.6.0 正式发布的阻断项。
|
||||
|
||||
## 10. Explicit exclusions
|
||||
|
||||
@@ -102,6 +100,9 @@ SLA、AI 质量评估和抑制规则都是 v0.6.0 正式发布的阻断项,但
|
||||
- 可视化或表单式 Playbook 编排器。
|
||||
- Playbook 中途人工审批。
|
||||
- 通用 HTTP/Webhook Connector 或统一厂商动作抽象。
|
||||
- UI/数据库驱动的 Suppression Rules;抑制逻辑由自定义 Module Python 代码负责。
|
||||
- Integration Health 定时探测和统一状态页;保留各 Settings 页手动 Test。
|
||||
- Worker/Integration 统一 Operations Center;Worker Health 使用独立实现。
|
||||
- 全站 UI 国际化。
|
||||
- 旧 CLI/插件兼容层。
|
||||
- 自动 Unmerge。
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# Worker Health
|
||||
|
||||
Status: Confirmed
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
让 Admin 不依赖 Docker CLI 或日志文件,就能判断后台 Worker 是否存活、正在做什么、是否失败以及业务积压情况。
|
||||
|
||||
v0.6.0 最终监控五个逻辑 Worker:
|
||||
|
||||
1. Agentic Module。
|
||||
2. Case Analysis。
|
||||
3. Playbook。
|
||||
4. ELK Action。
|
||||
5. Dashboard Cache。
|
||||
|
||||
监控粒度是逻辑进程类型,不是每个 Module/Playbook definition,也不是 Docker container ID。
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
Worker Health 基础设施集成进 `apps.common.worker_runner.run_worker()`。
|
||||
|
||||
每个 Worker:
|
||||
|
||||
- 启动时获取 Redis singleton lease。
|
||||
- 启动 daemon heartbeat thread。
|
||||
- 主线程在 iteration 边界更新执行状态与计数。
|
||||
- 优雅退出时释放 lease并留下安全退出原因。
|
||||
|
||||
Admin API 从 Redis 读取当前状态,并按 Worker 类型附加 backlog diagnostics。
|
||||
|
||||
## 3. Redis keys
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
worker-health:v1:{worker_type}:lease
|
||||
worker-health:v1:{worker_type}:state
|
||||
worker-health:v1:{worker_type}:last-exit
|
||||
```
|
||||
|
||||
### Lease
|
||||
|
||||
- value 至少包含随机 instance_id。
|
||||
- TTL 30 秒。
|
||||
- heartbeat 每 10 秒 compare-and-refresh,只有 owner instance_id 可续租。
|
||||
- 第二个同类型 Worker 发现有效 lease 后拒绝启动。
|
||||
- lease 过期后新实例可获取。
|
||||
- 不允许 last-writer-wins 覆盖。
|
||||
|
||||
### State
|
||||
|
||||
state 可与 lease 同一个 JSON key,也可独立;必须保证 owner 才能更新。
|
||||
|
||||
建议字段:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| worker_type | stable logical identifier |
|
||||
| instance_id | random UUID per process |
|
||||
| hostname | safe host/container hostname |
|
||||
| state | Starting/Idle/Running/Degraded |
|
||||
| started_at | process start |
|
||||
| heartbeat_at | last heartbeat |
|
||||
| iteration_started_at | current/last iteration start |
|
||||
| last_iteration_success_at | any successful iteration, including idle |
|
||||
| last_processed_at | last iteration that processed work |
|
||||
| last_failure_at | last failed/partial-failed iteration |
|
||||
| last_duration_ms | last iteration duration |
|
||||
| consecutive_failures | reset on successful iteration |
|
||||
| iteration_count | process-lifetime count |
|
||||
| processed_iteration_count | iterations with processed=true |
|
||||
| success_count | successful iterations |
|
||||
| failure_count | failed or partial-failed iterations |
|
||||
| last_message | safe WorkerIterationResult message |
|
||||
| last_error | normalized safe error |
|
||||
| log_role | log file role |
|
||||
|
||||
PID、完整 command、环境变量和 Secret 不通过 API 暴露。
|
||||
|
||||
### Last exit
|
||||
|
||||
优雅退出时记录短 TTL 或非租约型安全信息:
|
||||
|
||||
- instance_id
|
||||
- exited_at
|
||||
- reason=`graceful`
|
||||
|
||||
意外退出没有 last-exit update,API 通过 lease expired 判定。
|
||||
|
||||
## 4. Heartbeat
|
||||
|
||||
- daemon thread 每 10 秒刷新 lease。
|
||||
- 主线程运行长任务或 sleep 时仍持续刷新。
|
||||
- heartbeat Redis 写失败应记录安全日志;如果长期失败 lease 会过期。
|
||||
- heartbeat thread 不改变业务 iteration 状态。
|
||||
- Running 不设最大时长,也没有 Stalled。
|
||||
- 只要 heartbeat 存活,任意长任务保持 Running。
|
||||
|
||||
线程退出规则:
|
||||
|
||||
- Worker 正常 SIGTERM/KeyboardInterrupt 时停止 heartbeat。
|
||||
- 释放仅属于自身 instance_id 的 lease。
|
||||
- 不能删除新实例已取得的 lease。
|
||||
|
||||
## 5. Singleton startup
|
||||
|
||||
Worker 启动:
|
||||
|
||||
1. 生成 instance_id。
|
||||
2. 原子 SET NX 获取 lease。
|
||||
3. 失败则抛 CommandError 并退出,Compose 可记录重启失败。
|
||||
4. 成功写 Starting state。
|
||||
5. 启动 heartbeat thread。
|
||||
6. 进入主循环。
|
||||
|
||||
这适用于所有五个正式 Worker。`--once` 是运维命令,不应长期占用 singleton lease;如果它会与正式 Worker 并行影响同一数据,需要显式决定是否短暂获取 lease,默认建议获取以避免并发。
|
||||
|
||||
## 6. States
|
||||
|
||||
API 状态:
|
||||
|
||||
| State | Definition |
|
||||
| --- | --- |
|
||||
| Starting | live lease exists, first iteration not completed |
|
||||
| Idle | live lease, last iteration successful, currently no work |
|
||||
| Running | live lease, iteration currently executing |
|
||||
| Degraded | live lease, latest iteration has any failure |
|
||||
| Down | expected Worker has no live lease |
|
||||
|
||||
### Never reported
|
||||
|
||||
系统知道六种 expected worker type。没有 lease 且没有 state/last-exit 时:
|
||||
|
||||
- state=Down
|
||||
- reason=`never_reported`
|
||||
- UI 显示 Never reported
|
||||
|
||||
### Graceful and expired
|
||||
|
||||
- 正常退出:Down / graceful。
|
||||
- 意外崩溃或进程被杀:30 秒后 Down / heartbeat_expired。
|
||||
- Redis 本身不可用不是 Unknown/Down;整个 Worker Health API 返回 503。
|
||||
|
||||
### Degraded recovery
|
||||
|
||||
- 一次完整失败立即 Degraded。
|
||||
- `WorkerIterationResult.failure_count > 0` 的部分失败也立即 Degraded。
|
||||
- consecutive_failures 增加。
|
||||
- 下一次无失败的 iteration 清零并恢复 Idle 或完成后的健康状态。
|
||||
|
||||
## 7. WorkerIterationResult contract
|
||||
|
||||
扩展当前 dataclass:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class WorkerIterationResult:
|
||||
processed: bool = False
|
||||
message: str = ""
|
||||
failure_count: int = 0
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `processed` 表示至少完成一个业务工作项。
|
||||
- `failure_count` 表示 iteration 内被捕获的业务失败数。
|
||||
- 抛异常是完整 iteration failure。
|
||||
- `message` 必须是安全摘要。
|
||||
|
||||
### Time semantics
|
||||
|
||||
- 正常 idle polling 更新 `last_iteration_success_at`。
|
||||
- 只有 `processed=True` 更新 `last_processed_at`。
|
||||
- partial failure 不更新 success timestamp;是否同时有 processed 由结果如实记录。
|
||||
|
||||
## 8. Backlog diagnostics
|
||||
|
||||
不得强制统一为 queue_depth。
|
||||
|
||||
### Playbook
|
||||
|
||||
- Pending Run count。
|
||||
- oldest Pending age。
|
||||
- Running count。
|
||||
|
||||
### Case Analysis
|
||||
|
||||
- Pending job count。
|
||||
- oldest eligible scheduled job age。
|
||||
- Running count。
|
||||
|
||||
### Module
|
||||
|
||||
复用 Redis Stream health:
|
||||
|
||||
- stream length。
|
||||
- consumer group pending。
|
||||
- consumer count。
|
||||
- last-delivered-id。
|
||||
- 可计算的 lag。
|
||||
- 每个 Module definition 仍在现有 Custom 页面展示;Worker Health 可给汇总和跳转。
|
||||
|
||||
### ELK Action
|
||||
|
||||
外部索引完整积压不可可靠得知,只显示:
|
||||
|
||||
- last poll time。
|
||||
- last poll actions/sent/skipped。
|
||||
- last successful processed time。
|
||||
- current configured interval。
|
||||
|
||||
### Dashboard Cache
|
||||
|
||||
- 24h/7d/30d 各缓存 age。
|
||||
- generated/refreshed time。
|
||||
- configured interval。
|
||||
- cache missing 标记。
|
||||
|
||||
Backlog 查询失败不应使 heartbeat 消失。API 行级 diagnostics 可返回 safe warning。
|
||||
|
||||
## 9. Error safety
|
||||
|
||||
`last_error` 只包含:
|
||||
|
||||
- exception type。
|
||||
- 标准 reason code 或固定安全摘要。
|
||||
- failure time。
|
||||
- log role。
|
||||
|
||||
不包含:
|
||||
|
||||
- 原始 exception message。
|
||||
- traceback。
|
||||
- HTTP response。
|
||||
- SIEM query/event。
|
||||
- username、password、token、API key。
|
||||
|
||||
完整排查信息留在 Worker 日志。
|
||||
|
||||
## 10. API
|
||||
|
||||
建议:
|
||||
|
||||
`GET /api/settings/operations/workers/`
|
||||
|
||||
仅 Admin。
|
||||
|
||||
正常返回六行:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"worker_type": "playbook",
|
||||
"display_name": "Playbook Worker",
|
||||
"state": "Idle",
|
||||
"reason": "",
|
||||
"instance_id": "...",
|
||||
"hostname": "asp-worker-playbook",
|
||||
"started_at": "...",
|
||||
"heartbeat_at": "...",
|
||||
"last_iteration_success_at": "...",
|
||||
"last_processed_at": "...",
|
||||
"last_failure_at": null,
|
||||
"last_duration_ms": 8,
|
||||
"consecutive_failures": 0,
|
||||
"counters": {},
|
||||
"last_message": "",
|
||||
"last_error": null,
|
||||
"log_role": "agentic-playbook-worker",
|
||||
"backlog": {
|
||||
"pending_count": 0,
|
||||
"oldest_pending_age_seconds": null,
|
||||
"running_count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Redis health storage 无法访问:
|
||||
|
||||
- HTTP 503。
|
||||
- 固定信息:`Worker health monitoring is unavailable.`
|
||||
- 不返回五个伪造 Down 状态。
|
||||
|
||||
API 不提供:
|
||||
|
||||
- Restart。
|
||||
- Run Now。
|
||||
- Stop。
|
||||
- Tail/download logs。
|
||||
- 历史趋势。
|
||||
|
||||
## 11. Permissions and audit
|
||||
|
||||
- 仅 Admin 可访问 API/UI。
|
||||
- User/Viewer 403。
|
||||
- Health read 不写 AuditLog。
|
||||
- heartbeat/state change 不写 AuditLog。
|
||||
- 没有 Worker health notification。
|
||||
|
||||
## 12. Frontend
|
||||
|
||||
最终位置:`System Settings → Operations → Worker Health`。
|
||||
|
||||
行为:
|
||||
|
||||
- 每 10 秒自动刷新。
|
||||
- 提供手动 Refresh。
|
||||
- 503 显示页面级 monitoring unavailable,不显示全红 Down。
|
||||
- 状态 Tag:Starting、Idle、Running、Degraded、Down。
|
||||
- 每行显示心跳、最后处理、最后失败、错误摘要、积压和 log reference。
|
||||
- Worker-specific backlog 使用不同 detail 展示,不强行同列 JSON。
|
||||
- 不提供任何 mutation 按钮。
|
||||
- 可展示运维命令文本,但 Operations Center TODO 尚未确认具体命令 UX。
|
||||
|
||||
## 13. History and retention
|
||||
|
||||
- Redis 只保存当前状态。
|
||||
- 计数从进程启动开始。
|
||||
- Worker 重启后计数重置。
|
||||
- 不提供 uptime 百分比、趋势图或历史事件。
|
||||
- 日志是历史排查来源。
|
||||
|
||||
## 14. Compose changes
|
||||
|
||||
- 五个 Worker 使用明确 command 和 log role。
|
||||
- 不挂载 Docker socket。
|
||||
- 不通过 Web app 重启容器。
|
||||
- Worker singleton 冲突应在日志中明确显示。
|
||||
|
||||
## 15. Acceptance criteria
|
||||
|
||||
1. 五类 Worker 正常显示 Starting→Idle/Running。
|
||||
2. 第二个同类型实例拒绝启动。
|
||||
3. heartbeat 10 秒、30 秒过期行为正确。
|
||||
4. 长 Running 任务保持 heartbeat,不被判 Stalled/Down。
|
||||
5. 进程被强杀后约 30 秒显示 Down/heartbeat expired。
|
||||
6. 优雅退出显示 Down/graceful。
|
||||
7. 第一次 iteration/partial failure 立即 Degraded,后续成功恢复。
|
||||
8. idle success 与 actual processed 时间分离。
|
||||
9. Redis 不可用 API 503。
|
||||
10. 错误 API 不泄露原始异常或 Secret。
|
||||
11. 每类 backlog 数据符合专属 schema。
|
||||
12. User/Viewer 403,Admin 页面每 10 秒刷新。
|
||||
|
||||
## 16. Known tradeoffs
|
||||
|
||||
- Redis 故障时无法独立判断 Worker 健康。
|
||||
- 当前状态和计数不会跨进程重启保留。
|
||||
- daemon thread 增加少量线程复杂度,但解决了长任务 liveness。
|
||||
- Running 永不按时长 Stalled,业务死循环只要 heartbeat thread 活着就仍显示 Running。
|
||||
@@ -247,7 +247,7 @@ Warning 和 Breached 分别通知。同一 recipient 最多各一次。Worker
|
||||
新增单实例 `run_sla_worker`:
|
||||
|
||||
- 每 60 秒扫描。
|
||||
- 接入 Worker Health,成为第 7 个 Worker。
|
||||
- 接入 Worker Health,成为第 6 个 Worker。
|
||||
- 只负责发现需通知状态并去重发送。
|
||||
- API 状态仍动态计算,不依赖 Worker 更新状态。
|
||||
- 对适用且未完成的 TTA/TTR 使用 deadline 索引扫描。
|
||||
@@ -375,7 +375,7 @@ TTD 没有当前 Warning/Breach count。
|
||||
8. 80% Warning、100% Breached,完成后 late 仍 Breached。
|
||||
9. 旧 Case 无 SLA,新 Case有 SLA。
|
||||
10. merged source 排除,target 仅重算 TTD。
|
||||
11. SLA Worker 每分钟运行并进入 Worker Health。
|
||||
11. SLA Worker 每分钟运行并作为第 6 个 Worker 进入 Worker Health。
|
||||
12. 仅当前 Assignee 强制接收去重 Warning/Breach。
|
||||
13. 新 Assignee 可收到当前状态,未分配不通知。
|
||||
14. Case list/detail、Dashboard 和 Settings 行为符合 Spec。
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
# AI Quality Evaluation
|
||||
|
||||
Status: Confirmed
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
比较 Closed Case 的最终人工结构化判断与关闭前最后一次有效 AI 分析结果,形成可解释的 AI–Human Agreement 指标。
|
||||
|
||||
该功能不宣称人工标签是绝对真相,因此产品文案不用 Accuracy/Correctness。它不评价报告文字质量,也不自动优化 Prompt。
|
||||
|
||||
## 2. Evaluated fields
|
||||
|
||||
逐项比较:
|
||||
|
||||
- Verdict ↔ verdict_ai。
|
||||
- Severity ↔ severity_ai。
|
||||
- Impact ↔ impact_ai。
|
||||
- Priority ↔ priority_ai。
|
||||
- Confidence ↔ confidence_ai。
|
||||
|
||||
不合成总质量分。
|
||||
|
||||
## 3. Reference prediction
|
||||
|
||||
每个 Case 的主质量样本最多一个,使用:
|
||||
|
||||
1. status=Success 的 CaseAnalysisJob。
|
||||
2. completed_at 不晚于当前 closed_time。
|
||||
3. Job 原本针对该目标 Case 运行。
|
||||
4. 满足以上条件的最后一次 Job。
|
||||
|
||||
复用 `CaseAnalysisJob.result_json`,不创建独立 Prediction 表。
|
||||
|
||||
### Merge origin
|
||||
|
||||
案件合并会把历史 Job 迁入目标,但源上下文生成的 Job 不能作为目标预测。Job 必须保留 immutable origin Case identity;Evaluation 只选择 origin=target 的 Job。
|
||||
|
||||
## 4. Metadata exclusion
|
||||
|
||||
AI Quality 不新增、使用或展示:
|
||||
|
||||
- Provider。
|
||||
- Model。
|
||||
- Prompt ID/content/hash/language。
|
||||
- Profile version。
|
||||
- Trigger。
|
||||
- base URL。
|
||||
- Input payload。
|
||||
|
||||
即使 CaseAnalysisJob/AnalysisRecord 当前已有部分字段,全局质量页面也不按这些维度过滤。已接受的限制是无法比较模型或 Prompt 版本质量。
|
||||
|
||||
## 5. Evaluation trigger and lifecycle
|
||||
|
||||
### Create
|
||||
|
||||
Case 进入 Closed 时创建当前 Evaluation,快照:
|
||||
|
||||
- 参考 Job。
|
||||
- 五个 AI 值。
|
||||
- 五个人工值。
|
||||
- Case category。
|
||||
- Assignee。
|
||||
- closed_time。
|
||||
- evaluated_at。
|
||||
|
||||
### Closed field correction
|
||||
|
||||
Case 保持 Closed 时修改任一人工比较字段,提交 Case 后重建同一 Evaluation。Category/Assignee snapshot 也随重建刷新。
|
||||
|
||||
### Reopen
|
||||
|
||||
Case Reopen 时删除当前 Evaluation。开放 Case 不进入质量统计。再次 Closed 后重新创建,不保留第一次关闭的质量版本。
|
||||
|
||||
### Delete
|
||||
|
||||
Evaluation 不提供独立 mutation API,随 Case 删除级联。Reference CaseAnalysisJob 不允许独立删除。
|
||||
|
||||
### Failure isolation
|
||||
|
||||
Evaluation 构建失败不得阻止 Case Close、Reopen 或人工字段修改:
|
||||
|
||||
- Case transaction 先成功。
|
||||
- commit 后执行重建。
|
||||
- 失败写安全日志。
|
||||
- 提供 `rebuild_ai_quality_evaluations` 管理命令用于回填和修复。
|
||||
- 不新增专用 Worker。
|
||||
|
||||
实现不得返回“失败形状的成功”。Case API 可成功,但日志和后续 reconciliation 必须发现缺失 Evaluation。
|
||||
|
||||
## 6. Coverage states
|
||||
|
||||
每个 Closed Case Evaluation 状态:
|
||||
|
||||
- Evaluated:有 eligible Job 且 result_json 可解析。
|
||||
- No prediction:没有 eligible Job。
|
||||
- Invalid prediction:eligible 成功 Job 的 result_json 无法解析所需结构。
|
||||
|
||||
Prediction Coverage:
|
||||
|
||||
```text
|
||||
Evaluated Cases / all filtered non-merged Closed Cases
|
||||
```
|
||||
|
||||
No prediction 和 Invalid prediction 都在分母、不在分子。页面单独显示 Invalid count。
|
||||
|
||||
存在 Job 即使某个 AI 字段为空,Case 仍可为 Evaluated;该字段单独 Not evaluable。
|
||||
|
||||
## 7. Missing values
|
||||
|
||||
每个字段只有 AI 和人工值都存在时才进入该字段 agreement 分母。
|
||||
|
||||
- AI 空:Not evaluable。
|
||||
- 人工空:Not evaluable。
|
||||
- 双方空:Not evaluable。
|
||||
- Unknown 是显式有效值,不等于空。
|
||||
|
||||
Close 不强制五个人工字段全部填写;现有 Verdict 关闭约束保持。
|
||||
|
||||
## 8. Comparison semantics
|
||||
|
||||
### Verdict
|
||||
|
||||
- 使用完整 CaseVerdict 枚举。
|
||||
- exact agreement。
|
||||
- 完整 confusion matrix。
|
||||
- 不归并二分类或三分类。
|
||||
|
||||
### Ordinal fields
|
||||
|
||||
Severity、Impact、Priority、Confidence:
|
||||
|
||||
- exact agreement。
|
||||
- absolute ordinal distance。
|
||||
- AI overestimate。
|
||||
- AI underestimate。
|
||||
|
||||
等级顺序使用现有枚举的业务顺序。Unknown:
|
||||
|
||||
- 参与 exact agreement。
|
||||
- 进入 confusion counts。
|
||||
- 任一方 Unknown 时不计算 distance/direction。
|
||||
|
||||
### Naming
|
||||
|
||||
所有 UI/API 文案使用:
|
||||
|
||||
- AI–Human Agreement。
|
||||
- Agreement rate。
|
||||
- Mismatch。
|
||||
- Overestimate/Underestimate。
|
||||
|
||||
不使用 AI Accuracy、Correctness 或 analyst accuracy。
|
||||
|
||||
## 9. Data model
|
||||
|
||||
每 Case 一条 OneToOne `AiQualityEvaluation`,显式字段而非 JSON。
|
||||
|
||||
建议字段:
|
||||
|
||||
- case OneToOne。
|
||||
- reference_job nullable FK。
|
||||
- coverage_state。
|
||||
- ai_verdict / human_verdict / verdict_agrees。
|
||||
- ai_severity / human_severity / severity_agrees / severity_distance / severity_direction。
|
||||
- ai_impact / human_impact / impact_agrees / impact_distance / impact_direction。
|
||||
- ai_priority / human_priority / priority_agrees / priority_distance / priority_direction。
|
||||
- ai_confidence / human_confidence / confidence_agrees / confidence_distance / confidence_direction。
|
||||
- category_snapshot。
|
||||
- assignee_snapshot nullable FK。
|
||||
- closed_at。
|
||||
- evaluated_at。
|
||||
|
||||
派生字段保存为显式 nullable 列,方便 PostgreSQL 聚合与筛选;重建时一次计算。
|
||||
|
||||
建议索引:
|
||||
|
||||
- closed_at。
|
||||
- coverage_state。
|
||||
- category_snapshot。
|
||||
- assignee_snapshot。
|
||||
- human_severity。
|
||||
- 各 agrees 字段按实际查询计划决定组合索引。
|
||||
|
||||
## 10. Case merge
|
||||
|
||||
- merged source Evaluation 保留在只读源 Case。
|
||||
- merged source 从所有全局聚合和默认样本列表排除。
|
||||
- target 不继承 source Evaluation。
|
||||
- 迁入的 source-origin Jobs 不作为 target 参考。
|
||||
- target 后续 Closed 时按自己的 eligible Job 创建 Evaluation。
|
||||
|
||||
## 11. Historical backfill
|
||||
|
||||
升级时/升级后管理命令回填:
|
||||
|
||||
- 当前 Closed。
|
||||
- 未合并。
|
||||
- 使用当前人工字段。
|
||||
- 选择 closed_time 前最后 eligible 成功 Job。
|
||||
- 没有 Job 创建 No prediction。
|
||||
- 解析失败创建 Invalid prediction。
|
||||
- 不调用 LLM。
|
||||
- 不产生通知或 Evaluation AuditLog。
|
||||
|
||||
## 12. Permissions
|
||||
|
||||
| Surface | Admin | User | Viewer |
|
||||
| --- | --- | --- | --- |
|
||||
| Current Case comparison | Yes | Yes | Yes |
|
||||
| Global summary | Yes | No | No |
|
||||
| Global samples | Yes | No | No |
|
||||
| Mutation | No | No | No |
|
||||
|
||||
Assignee 可用于 Admin 筛选,但不提供分析师排行榜、最好/最差排名或绩效分。
|
||||
|
||||
## 13. Global analytics
|
||||
|
||||
位置:`System Settings → AI Quality`。
|
||||
|
||||
默认最近 30 天,时间维度为 Case closed_time。
|
||||
|
||||
筛选:
|
||||
|
||||
- closed time range。
|
||||
- category snapshot。
|
||||
- human severity。
|
||||
- assignee snapshot。
|
||||
- coverage state。
|
||||
|
||||
不按 model/provider/prompt/profile/trigger 过滤。
|
||||
|
||||
### Required metrics
|
||||
|
||||
- Prediction Coverage 和 total count。
|
||||
- Evaluated/No prediction/Invalid counts。
|
||||
- 五字段 exact agreement rate + sample count。
|
||||
- Verdict confusion matrix。
|
||||
- 四个 ordinal 字段 mean absolute distance。
|
||||
- 四个 ordinal 字段 over/under/match counts。
|
||||
- 五字段 agreement trend。
|
||||
|
||||
不提供:
|
||||
|
||||
- composite score。
|
||||
- pass/fail threshold。
|
||||
- 红黄绿目标。
|
||||
- severity weighted score。
|
||||
- analyst leaderboard。
|
||||
|
||||
### Trends
|
||||
|
||||
- ≤31 天:daily。
|
||||
- 32–180 天:weekly。
|
||||
- >180 天:monthly。
|
||||
- 每点包含 sample count。
|
||||
- 空桶不返回,不显示为 0%。
|
||||
|
||||
## 14. Sample drilldown
|
||||
|
||||
Admin-only 分页表:
|
||||
|
||||
- Case ID/title link。
|
||||
- closed_at。
|
||||
- assignee snapshot。
|
||||
- category/human severity。
|
||||
- coverage state。
|
||||
- 五组 AI/human values。
|
||||
- agreement/direction/distance。
|
||||
|
||||
筛选:
|
||||
|
||||
- mismatch field。
|
||||
- only mismatches。
|
||||
- global filters。
|
||||
|
||||
不在表中返回完整 Investigation Report、Analysis input、知识上下文或原始 Job JSON。
|
||||
|
||||
## 15. Case UI
|
||||
|
||||
现有 Investigation Tab 顶部增加五行对比表:
|
||||
|
||||
- Field。
|
||||
- AI value。
|
||||
- Human value。
|
||||
- Agreement。
|
||||
- Direction/distance(适用时)。
|
||||
|
||||
显示:
|
||||
|
||||
- No prediction。
|
||||
- Invalid prediction。
|
||||
- Not evaluable。
|
||||
|
||||
不新增独立 Case Quality Tab。完整报告继续使用现有 Investigation view。
|
||||
|
||||
## 16. API
|
||||
|
||||
### Global summary
|
||||
|
||||
`GET /api/ai-quality/summary/`
|
||||
|
||||
- Admin-only。
|
||||
- 接收已确认筛选。
|
||||
- 返回 coverage、agreement、matrix、ordinal stats、trend。
|
||||
- PostgreSQL 实时聚合。
|
||||
|
||||
### Samples
|
||||
|
||||
`GET /api/ai-quality/evaluations/`
|
||||
|
||||
- Admin-only。
|
||||
- cursor/page pagination。
|
||||
- 返回安全结构化快照。
|
||||
|
||||
### Case surface
|
||||
|
||||
Case Investigation 或 Case detail 的只读字段返回当前 Evaluation。User/Viewer 可读。
|
||||
|
||||
无 create/update/delete API,无 CSV/JSON export。
|
||||
|
||||
## 17. Aggregation
|
||||
|
||||
- 实时 PostgreSQL 查询。
|
||||
- 默认 30 天。
|
||||
- medium 基线最多约 10,000 Evaluation,不新增缓存/Worker/materialized daily table。
|
||||
- closed_at 和筛选维度必须有适当索引。
|
||||
- API 必须返回 sample counts,避免小样本误解。
|
||||
|
||||
## 18. Audit
|
||||
|
||||
- Evaluation create/rebuild/delete 不写 AuditLog。
|
||||
- Admin 查看 summary/sample 不写 AuditLog。
|
||||
- Case Close/Reopen/人工字段修改沿用现有 Case audit。
|
||||
- reference_job 和 evaluated_at 用于技术追溯。
|
||||
|
||||
## 19. Acceptance criteria
|
||||
|
||||
1. 五个字段比较正确且不合成总分。
|
||||
2. 最新 eligible pre-close Job 选择正确。
|
||||
3. source-origin merged Job 被排除。
|
||||
4. Unknown 和 empty 语义正确。
|
||||
5. Verdict matrix 和 ordinal direction/distance 正确。
|
||||
6. Close 创建,Closed edit 重建,Reopen 删除,Reclose 重建。
|
||||
7. Evaluation 故障不阻止 Case 业务操作,并可管理命令修复。
|
||||
8. 历史 Closed Case 正确回填,不调用 LLM。
|
||||
9. merged source 保留但排除统计。
|
||||
10. Coverage denominator/numerator 和 Invalid count 正确。
|
||||
11. Admin-only 全局页面,所有角色单案可见。
|
||||
12. 筛选使用 Evaluation snapshot 和 closed_time。
|
||||
13. 自适应趋势和 sample count 正确。
|
||||
14. API 不暴露 report/input/provider/model/prompt metadata。
|
||||
15. medium 数据实时聚合满足最终验收阈值。
|
||||
|
||||
## 20. Known tradeoffs
|
||||
|
||||
- 无模型/Prompt 元数据,无法解释版本变化导致的趋势。
|
||||
- 人工字段只是参考标签,不是绝对 ground truth。
|
||||
- Closed 后重建覆盖旧质量结果,不保留 closure version。
|
||||
- Evaluation 失败与 Case 关闭隔离,短时间内统计可能缺少样本,依赖 reconciliation。
|
||||
- 无报告正文质量反馈、导出、阈值和自动学习。
|
||||
@@ -10,19 +10,19 @@
|
||||
| [01-bulk-case-triage.md](01-bulk-case-triage.md) | Confirmed | Case 批量分诊、共享状态机、通知与审计 |
|
||||
| [02-case-merge.md](02-case-merge.md) | Confirmed | 多源案件合并、数据迁移、只读源案件与幂等 |
|
||||
| [03-playbook-execution.md](03-playbook-execution.md) | Confirmed | Playbook Run、结构化 Stage、取消、重试与 Worker 语义 |
|
||||
| [05-worker-health.md](05-worker-health.md) | Confirmed | Redis 心跳、Worker 状态、积压指标与 Admin API |
|
||||
| [07-sla-management.md](07-sla-management.md) | Confirmed | TTD/TTA/TTR 时限、Severity 策略、通知和 Dashboard 达标率 |
|
||||
| [08-ai-quality-evaluation.md](08-ai-quality-evaluation.md) | Confirmed | AI–Human Agreement、Coverage、混淆矩阵和样本下钻 |
|
||||
|
||||
## 待讨论
|
||||
|
||||
[TODO-remaining-domains.md](TODO-remaining-domains.md) 记录 AI 质量评估、抑制规则、Operations 页面整合和版本验收。继续讨论时应逐项把决定写回独立 Spec。
|
||||
[TODO-remaining-domains.md](TODO-remaining-domains.md) 仅记录版本验收。所有 v0.6.0 功能域均已确认或明确排除。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 先完成 Case 状态机,再实现批量分诊和案件合并。
|
||||
2. 完成 Playbook Run/Stage 后实现 Custom Variables。
|
||||
3. 完成通用 Worker Health 基础设施并接入五类 Worker,再实现 Operations 页面。
|
||||
4. 待剩余三个业务域定稿后,统一补齐 v0.6.0 验收规范。
|
||||
2. 完成 Playbook Run/Stage。
|
||||
3. 完成 SLA 和 AI Quality。
|
||||
4. 最后统一补齐 v0.6.0 验收规范。
|
||||
|
||||
## Spec 使用规则
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# v0.6.0 remaining domain TODO
|
||||
|
||||
Status: Acceptance only
|
||||
|
||||
所有功能域已经确认或明确排除。剩余工作只有版本验收规范。
|
||||
|
||||
## TODO 1: v0.6.0 acceptance
|
||||
|
||||
Release blocking: Yes
|
||||
|
||||
需要根据全部 Confirmed Spec 定义:
|
||||
|
||||
- 从 v0.5.2 生产备份副本升级的完整演练。
|
||||
- 全新 Docker Compose 安装。
|
||||
- medium 数据规模下的关键 API/页面性能阈值。
|
||||
- Admin/User/Viewer 权限矩阵回归。
|
||||
- Case 单条和批量状态机一致性。
|
||||
- 部分成功批量分诊、原子合并、幂等重试和 merged source 路由。
|
||||
- Playbook Pending/Running/终态、Cancel、Retry、Stage 和 Worker 崩溃恢复。
|
||||
- Custom Variables 的 Secret masking、Reveal audit 和 Module/Playbook 读取。
|
||||
- Worker Redis 故障、重复实例、优雅退出和心跳过期。
|
||||
- SLA 的 TTD/TTA/TTR、通知、Reopen、merge 和 Dashboard 达标率。
|
||||
- AI Quality 的回填、Coverage、Agreement、合并排除和权限。
|
||||
- 已明确排除的 Integration Health、Suppression Rules、Operations Center 不得误入发布范围。
|
||||
- 备份、恢复和回滚演练。
|
||||
- 文档、release notes、已知限制和功能冻结条件。
|
||||
- P0/P1 缺陷门槛和 RC 观察周期。
|
||||
|
||||
输出目标:`11-release-acceptance.md`。
|
||||
|
||||
## Suggested continuation prompt
|
||||
|
||||
> 阅读 `docs/specs/v0.6.0/README.md` 和全部 Confirmed/Excluded 决策。不要重新讨论功能范围。根据这些 Spec 一次只确认一个发布验收决策,完成后输出 `11-release-acceptance.md`。
|
||||
@@ -13,9 +13,11 @@
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.6.7",
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"@uiw/react-json-view": "2.0.0-alpha.43",
|
||||
"@uiw/react-md-editor": "^4.1.1",
|
||||
"antd": "^6.5.1",
|
||||
|
||||
Generated
+201
@@ -14,6 +14,9 @@ importers:
|
||||
'@ant-design/icons':
|
||||
specifier: ^6.3.2
|
||||
version: 6.3.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@codemirror/lang-json':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -23,6 +26,9 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.7)
|
||||
'@uiw/react-codemirror':
|
||||
specifier: ^4.25.11
|
||||
version: 4.25.11(@babel/runtime@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.7)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@uiw/react-json-view':
|
||||
specifier: 2.0.0-alpha.43
|
||||
version: 2.0.0-alpha.43(@babel/runtime@8.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -314,6 +320,33 @@ packages:
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
|
||||
|
||||
'@codemirror/commands@6.10.4':
|
||||
resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==}
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||
|
||||
'@codemirror/language@6.12.4':
|
||||
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
|
||||
|
||||
'@codemirror/lint@6.9.7':
|
||||
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
|
||||
|
||||
'@codemirror/search@6.7.1':
|
||||
resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==}
|
||||
|
||||
'@codemirror/state@6.7.1':
|
||||
resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||
|
||||
'@codemirror/view@6.43.7':
|
||||
resolution: {integrity: sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
@@ -432,6 +465,21 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lezer/common@1.5.2':
|
||||
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||
|
||||
'@lezer/json@1.0.3':
|
||||
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
|
||||
|
||||
'@lezer/lr@1.4.10':
|
||||
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.3':
|
||||
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
peerDependencies:
|
||||
@@ -982,9 +1030,31 @@ packages:
|
||||
resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.11':
|
||||
resolution: {integrity: sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==}
|
||||
peerDependencies:
|
||||
'@codemirror/autocomplete': '>=6.0.0'
|
||||
'@codemirror/commands': '>=6.0.0'
|
||||
'@codemirror/language': '>=6.0.0'
|
||||
'@codemirror/lint': '>=6.0.0'
|
||||
'@codemirror/search': '>=6.0.0'
|
||||
'@codemirror/state': '>=6.0.0'
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
|
||||
'@uiw/copy-to-clipboard@1.0.21':
|
||||
resolution: {integrity: sha512-apdzZJyJC/IEj21N22ry1H022pgpSA+FwNKxmvOGQ9rUMdyRbHf1nZq2UwqnfGOuTr7iOKLzNgWsCJtAwyAZpw==}
|
||||
|
||||
'@uiw/react-codemirror@4.25.11':
|
||||
resolution: {integrity: sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==}
|
||||
peerDependencies:
|
||||
'@babel/runtime': '>=7.11.0'
|
||||
'@codemirror/state': '>=6.0.0'
|
||||
'@codemirror/theme-one-dark': '>=6.0.0'
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
codemirror: '>=6.0.0'
|
||||
react: '>=17.0.0'
|
||||
react-dom: '>=17.0.0'
|
||||
|
||||
'@uiw/react-json-view@2.0.0-alpha.43':
|
||||
resolution: {integrity: sha512-PMj+6xRDCPbVGccfLUDhx7yGrmVBSeFx3py6bqDds4QIYScXo9GEgtTbWXn3gXpoehsmOu//6dveYTf872wu3Q==}
|
||||
peerDependencies:
|
||||
@@ -1116,6 +1186,9 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
codemirror@6.0.2:
|
||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
@@ -1146,6 +1219,9 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
crelt@1.0.7:
|
||||
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2135,6 +2211,9 @@ packages:
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
style-mod@4.1.3:
|
||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||
|
||||
@@ -2299,6 +2378,9 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
web-namespaces@2.0.1:
|
||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||
|
||||
@@ -2756,6 +2838,64 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.7
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/commands@6.10.4':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.7
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@lezer/json': 1.0.3
|
||||
|
||||
'@codemirror/language@6.12.4':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.7
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/lint@6.9.7':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.7
|
||||
crelt: 1.0.7
|
||||
|
||||
'@codemirror/search@6.7.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.7
|
||||
crelt: 1.0.7
|
||||
|
||||
'@codemirror/state@6.7.1':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.3
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.7
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.43.7':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.1
|
||||
crelt: 1.0.7
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
@@ -2876,6 +3016,24 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lezer/common@1.5.2': {}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@lezer/json@1.0.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/lr@1.4.10':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@marijn/find-cluster-break@1.0.3': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.1
|
||||
@@ -3461,8 +3619,35 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.64.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.7)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.10.4
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/search': 6.7.1
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.7
|
||||
|
||||
'@uiw/copy-to-clipboard@1.0.21': {}
|
||||
|
||||
'@uiw/react-codemirror@4.25.11(@babel/runtime@8.0.0)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.7)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
'@codemirror/commands': 6.10.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/theme-one-dark': 6.1.3
|
||||
'@codemirror/view': 6.43.7
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.7)
|
||||
codemirror: 6.0.2
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/autocomplete'
|
||||
- '@codemirror/language'
|
||||
- '@codemirror/lint'
|
||||
- '@codemirror/search'
|
||||
|
||||
'@uiw/react-json-view@2.0.0-alpha.43(@babel/runtime@8.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
@@ -3649,6 +3834,16 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
codemirror@6.0.2:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.10.4
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/search': 6.7.1
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.7
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
color-string@1.9.1:
|
||||
@@ -3672,6 +3867,8 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
crelt@1.0.7: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -4963,6 +5160,8 @@ snapshots:
|
||||
character-entities-html4: 2.1.0
|
||||
character-entities-legacy: 3.0.0
|
||||
|
||||
style-mod@4.1.3: {}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
dependencies:
|
||||
style-to-object: 1.0.14
|
||||
@@ -5111,6 +5310,8 @@ snapshots:
|
||||
'@types/node': 24.13.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Key} from 'react'
|
||||
import type {CSSProperties, Key} from 'react'
|
||||
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'
|
||||
import {Button, Checkbox, Divider, Input, Pagination, Popconfirm, Popover, Select, Space, Table, Tooltip} from 'antd'
|
||||
import {message} from '../utils/appMessage'
|
||||
@@ -775,6 +775,9 @@ export default function DataTable<RecordType extends Record<string, unknown> = R
|
||||
const activeFilterCount = filterState.advanced.filter(isActiveAdvancedFilter).length
|
||||
const filterSummary = savedFilterName || (activeFilterCount > 0 ? `${activeFilterCount} filter(s)` : '')
|
||||
const renderedActions = typeof actions === 'function' ? actions({ params: tableParams }) : actions
|
||||
const tableStyle: CSSProperties & {'--asp-table-body-height': string} = {
|
||||
'--asp-table-body-height': `${tableBodyHeight}px`,
|
||||
}
|
||||
const searchControl = (
|
||||
<Input.Search
|
||||
placeholder={searchPlaceholder}
|
||||
@@ -902,6 +905,7 @@ export default function DataTable<RecordType extends Record<string, unknown> = R
|
||||
<Table<RecordType>
|
||||
key={tableResetKey}
|
||||
className="asp-data-table"
|
||||
style={tableStyle}
|
||||
columns={antColumns}
|
||||
dataSource={data}
|
||||
rowKey={rowKey}
|
||||
|
||||
@@ -106,6 +106,19 @@ const customVariableConfiguredTag = (v: unknown) => {
|
||||
const configured = v === true || v === 'true'
|
||||
return choiceTag(configured ? 'Configured' : 'Not configured', configured ? 'green' : 'red')
|
||||
}
|
||||
const customVariableTypeOptions = [
|
||||
{label: 'String', value: 'string'},
|
||||
{label: 'Integer', value: 'integer'},
|
||||
{label: 'Float', value: 'float'},
|
||||
{label: 'Boolean', value: 'boolean'},
|
||||
{label: 'List', value: 'list'},
|
||||
{label: 'Dictionary', value: 'dictionary'},
|
||||
]
|
||||
const customVariableTypeTag = (v: unknown) => {
|
||||
const type = String(v || '')
|
||||
const option = customVariableTypeOptions.find((item) => item.value === type)
|
||||
return choiceTag(option?.label || type, 'blue')
|
||||
}
|
||||
const llmTagColors: Record<string, string> = {
|
||||
fast: 'blue',
|
||||
powerful: 'purple',
|
||||
@@ -1027,12 +1040,14 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
|
||||
rowKey: 'id',
|
||||
searchPlaceholder: 'Key, Description',
|
||||
filters: [
|
||||
{key: 'value_type', label: 'Type', valueType: 'select', options: customVariableTypeOptions, width: L160},
|
||||
{key: 'is_secret', label: 'Secret', valueType: 'select', options: [{label: 'Secret', value: 'true'}, {label: 'Plain', value: 'false'}], width: L132},
|
||||
{key: 'enabled', label: 'Enabled', valueType: 'select', options: [{label: 'Enabled', value: 'true'}, {label: 'Disabled', value: 'false'}], width: L132},
|
||||
],
|
||||
advancedFilters: [
|
||||
field('key', 'Key', 'text'),
|
||||
field('description', 'Description', 'text'),
|
||||
{key: 'value_type', label: 'Type', valueType: 'select', options: customVariableTypeOptions},
|
||||
{key: 'is_secret', label: 'Secret', valueType: 'select', options: [{label: 'Secret', value: 'true'}, {label: 'Plain', value: 'false'}]},
|
||||
{key: 'enabled', label: 'Enabled', valueType: 'select', options: [{label: 'Enabled', value: 'true'}, {label: 'Disabled', value: 'false'}]},
|
||||
field('created_at', 'Created At', 'date'),
|
||||
@@ -1041,6 +1056,7 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
|
||||
columns: [
|
||||
column('key', 'Key', L240, {required: true, defaultVisible: true, openRecord: true, sorter: true}),
|
||||
column('description', 'Description', L360, {defaultVisible: true}),
|
||||
column('value_type', 'Type', L132, {defaultVisible: true, sorter: true, render: customVariableTypeTag}),
|
||||
column('is_secret', 'Secret', L96, {defaultVisible: true, sorter: true, render: customVariableSecretTag}),
|
||||
column('enabled', 'Enabled', L96, {defaultVisible: true, sorter: true, render: llmProviderStatusTag}),
|
||||
column('value_configured', 'Value', L132, {defaultVisible: true, render: customVariableConfiguredTag}),
|
||||
|
||||
@@ -113,6 +113,10 @@ input:-webkit-autofill:active,
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.asp-data-table .ant-table-body {
|
||||
min-height: var(--asp-table-body-height);
|
||||
}
|
||||
|
||||
.asp-table-floating-help {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {App as AntApp, Button, Form, Input, Modal, Popconfirm, Space, Switch, Typography} from 'antd'
|
||||
import {App as AntApp, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Typography} from 'antd'
|
||||
import {DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined} from '@ant-design/icons'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import {json} from '@codemirror/lang-json'
|
||||
import client from '../api/client'
|
||||
import DataTable from '../components/DataTable'
|
||||
import {getResourceConfig} from '../config/resources'
|
||||
@@ -9,7 +11,8 @@ import {message} from '../utils/appMessage'
|
||||
type CustomVariable = Record<string, unknown> & {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
value_type: CustomVariableValueType
|
||||
value: unknown
|
||||
value_configured: boolean
|
||||
is_secret: boolean
|
||||
description: string
|
||||
@@ -18,9 +21,12 @@ type CustomVariable = Record<string, unknown> & {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type CustomVariableValueType = 'string' | 'integer' | 'float' | 'boolean' | 'list' | 'dictionary'
|
||||
|
||||
interface CustomVariableFormValues {
|
||||
key: string
|
||||
value?: string
|
||||
value_type: CustomVariableValueType
|
||||
value?: unknown
|
||||
is_secret: boolean
|
||||
description?: string
|
||||
enabled: boolean
|
||||
@@ -32,6 +38,16 @@ interface RevealedValue {
|
||||
}
|
||||
|
||||
const MAX_VALUE_BYTES = 65_536
|
||||
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER
|
||||
const JSON_EDITOR_EXTENSIONS = [json()]
|
||||
const VALUE_TYPE_OPTIONS = [
|
||||
{label: 'String', value: 'string'},
|
||||
{label: 'Integer', value: 'integer'},
|
||||
{label: 'Float', value: 'float'},
|
||||
{label: 'Boolean', value: 'boolean'},
|
||||
{label: 'List', value: 'list'},
|
||||
{label: 'Dictionary', value: 'dictionary'},
|
||||
]
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
const data = (error as { response?: { data?: unknown } }).response?.data
|
||||
@@ -47,6 +63,7 @@ function apiErrorMessage(error: unknown, fallback: string) {
|
||||
function initialValues(): CustomVariableFormValues {
|
||||
return {
|
||||
key: '',
|
||||
value_type: 'string',
|
||||
value: '',
|
||||
is_secret: false,
|
||||
description: '',
|
||||
@@ -65,6 +82,7 @@ export default function CustomVariablesSettings() {
|
||||
const [revealed, setRevealed] = useState<RevealedValue | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const isSecret = Form.useWatch('is_secret', form) ?? false
|
||||
const valueType = Form.useWatch('value_type', form) ?? 'string'
|
||||
|
||||
const refresh = () => setRefreshKey((value) => value + 1)
|
||||
|
||||
@@ -78,7 +96,12 @@ export default function CustomVariablesSettings() {
|
||||
try {
|
||||
const {data} = await client.get<CustomVariable>(`/custom/variables/${record.id}/`)
|
||||
setEditing(data)
|
||||
form.setFieldsValue({...initialValues(), ...data})
|
||||
const value = data.is_secret
|
||||
? ''
|
||||
: data.value_type === 'list' || data.value_type === 'dictionary'
|
||||
? JSON.stringify(data.value, null, 2)
|
||||
: data.value
|
||||
form.setFieldsValue({...initialValues(), ...data, value})
|
||||
setModalOpen(true)
|
||||
} catch (error: unknown) {
|
||||
message.error(apiErrorMessage(error, 'Failed to load custom variable'))
|
||||
@@ -93,12 +116,18 @@ export default function CustomVariablesSettings() {
|
||||
|
||||
const persist = async (values: CustomVariableFormValues, confirmSecretExposure: boolean) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
value_type: values.value_type,
|
||||
is_secret: values.is_secret,
|
||||
description: values.description || '',
|
||||
enabled: values.enabled,
|
||||
}
|
||||
if (!editing) payload.key = values.key
|
||||
if (values.value !== '' && values.value !== undefined) payload.value = values.value
|
||||
const omitUnchangedSecret = Boolean(editing?.is_secret && values.value_type === 'string' && values.value === '')
|
||||
if (!omitUnchangedSecret) {
|
||||
payload.value = values.value_type === 'list' || values.value_type === 'dictionary'
|
||||
? JSON.parse(String(values.value))
|
||||
: values.value
|
||||
}
|
||||
if (confirmSecretExposure) payload.confirm_secret_exposure = true
|
||||
|
||||
if (editing) {
|
||||
@@ -116,12 +145,18 @@ export default function CustomVariablesSettings() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
if (editing?.is_secret && !values.is_secret) {
|
||||
const exposesSecret = Boolean(editing?.is_secret && !values.is_secret)
|
||||
const changesType = Boolean(editing && editing.value_type !== values.value_type)
|
||||
if (exposesSecret || changesType) {
|
||||
modal.confirm({
|
||||
title: `Expose ${editing.key} as a non-secret variable?`,
|
||||
content: 'This value will become visible in normal Admin API responses and UI.',
|
||||
okText: 'Expose value',
|
||||
okButtonProps: {danger: true},
|
||||
title: exposesSecret
|
||||
? `Expose ${editing?.key} as a non-secret variable?`
|
||||
: `Change ${editing?.key} from ${editing?.value_type} to ${values.value_type}?`,
|
||||
content: exposesSecret
|
||||
? 'This value will become visible in normal Admin API responses and UI.'
|
||||
: 'Playbooks and modules will receive a different Python value type.',
|
||||
okText: exposesSecret ? 'Expose value' : 'Change type',
|
||||
okButtonProps: {danger: exposesSecret},
|
||||
onOk: async () => {
|
||||
try {
|
||||
await persist(values, true)
|
||||
@@ -163,16 +198,53 @@ export default function CustomVariablesSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const validateValue = (_: unknown, value: string | undefined) => {
|
||||
if (!value && !editing?.is_secret) {
|
||||
const validateValue = (_: unknown, value: unknown) => {
|
||||
const unchangedSecret = editing?.is_secret && valueType === 'string' && value === ''
|
||||
if (unchangedSecret) return Promise.resolve()
|
||||
if (value === undefined || value === null || (valueType === 'string' && value === '')) {
|
||||
return Promise.reject(new Error('Value is required.'))
|
||||
}
|
||||
if (value !== undefined && new TextEncoder().encode(value).length > MAX_VALUE_BYTES) {
|
||||
if (valueType === 'integer' && (typeof value !== 'number' || !Number.isSafeInteger(value))) {
|
||||
return Promise.reject(new Error('Value must be a safe integer.'))
|
||||
}
|
||||
let serialized = String(value)
|
||||
if (valueType === 'list' || valueType === 'dictionary') {
|
||||
try {
|
||||
const parsed = JSON.parse(serialized)
|
||||
if (valueType === 'list' && !Array.isArray(parsed)) {
|
||||
return Promise.reject(new Error('Value must be a JSON list.'))
|
||||
}
|
||||
if (valueType === 'dictionary' && (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object')) {
|
||||
return Promise.reject(new Error('Value must be a JSON dictionary.'))
|
||||
}
|
||||
serialized = JSON.stringify(parsed)
|
||||
} catch {
|
||||
return Promise.reject(new Error('Value must be valid JSON.'))
|
||||
}
|
||||
}
|
||||
if (new TextEncoder().encode(serialized).length > MAX_VALUE_BYTES) {
|
||||
return Promise.reject(new Error(`Value cannot exceed ${MAX_VALUE_BYTES.toLocaleString()} UTF-8 bytes.`))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const changeValueType = (nextType: CustomVariableValueType) => {
|
||||
form.setFieldsValue({
|
||||
value_type: nextType,
|
||||
value: nextType === 'boolean' ? false : undefined,
|
||||
is_secret: nextType === 'string' ? form.getFieldValue('is_secret') : false,
|
||||
})
|
||||
}
|
||||
|
||||
const formatStructuredValue = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(form.getFieldValue('value') ?? ''))
|
||||
form.setFieldValue('value', JSON.stringify(parsed, null, 2))
|
||||
} catch {
|
||||
message.error('Value must be valid JSON before it can be formatted')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{height: 'calc(100vh - 188px)', minHeight: 360}}>
|
||||
<DataTable
|
||||
@@ -241,6 +313,7 @@ export default function CustomVariablesSettings() {
|
||||
<Modal
|
||||
title={editing ? `Custom Variable: ${editing.key}` : 'Add Custom Variable'}
|
||||
open={modalOpen}
|
||||
width={valueType === 'list' || valueType === 'dictionary' ? 760 : undefined}
|
||||
onCancel={closeEditor}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
@@ -261,23 +334,72 @@ export default function CustomVariablesSettings() {
|
||||
>
|
||||
<Input disabled={editing !== null} placeholder="EDR_API_TOKEN" maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="value_type"
|
||||
label="Type"
|
||||
rules={[{required: true}]}
|
||||
>
|
||||
<Select options={VALUE_TYPE_OPTIONS} onChange={changeValueType} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="value"
|
||||
label="Value"
|
||||
valuePropName={valueType === 'boolean' ? 'checked' : 'value'}
|
||||
label={(
|
||||
<Space>
|
||||
<span>Value</span>
|
||||
{(valueType === 'list' || valueType === 'dictionary')
|
||||
? <Button type="link" size="small" onClick={formatStructuredValue}>Format</Button>
|
||||
: null}
|
||||
</Space>
|
||||
)}
|
||||
rules={[{validator: validateValue}]}
|
||||
extra={editing?.is_secret
|
||||
? 'Leave blank to keep the current value. Maximum 65,536 UTF-8 bytes.'
|
||||
: 'Maximum 65,536 UTF-8 bytes.'}
|
||||
>
|
||||
{isSecret
|
||||
? <Input.Password autoComplete="new-password" />
|
||||
: <Input.TextArea autoSize={{minRows: 3, maxRows: 10}} />}
|
||||
{valueType === 'boolean' ? (
|
||||
<Switch />
|
||||
) : valueType === 'integer' ? (
|
||||
<InputNumber
|
||||
min={-MAX_SAFE_INTEGER}
|
||||
max={MAX_SAFE_INTEGER}
|
||||
precision={0}
|
||||
style={{width: '100%'}}
|
||||
/>
|
||||
) : valueType === 'float' ? (
|
||||
<InputNumber style={{width: '100%'}} />
|
||||
) : isSecret ? (
|
||||
<Input.Password autoComplete="new-password" />
|
||||
) : valueType === 'list' || valueType === 'dictionary' ? (
|
||||
<CodeMirror
|
||||
height="320px"
|
||||
theme="dark"
|
||||
extensions={JSON_EDITOR_EXTENSIONS}
|
||||
placeholder={valueType === 'list' ? '[\n \n]' : '{\n \n}'}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: true,
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid rgba(253, 253, 253, 0.12)',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input.TextArea autoSize={{minRows: 3, maxRows: 10}} />
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea autoSize={{minRows: 2, maxRows: 5}} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_secret" label="Secret" valuePropName="checked">
|
||||
<Switch />
|
||||
<Switch disabled={valueType !== 'string'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="Enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
|
||||
Reference in New Issue
Block a user