feat(custom): support typed variables

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
rookit
2026-08-03 13:22:11 +08:00
co-authored by Copilot
parent 02c3ab138f
commit a5cb9f8c59
9 changed files with 567 additions and 41 deletions
+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(),
),
]
+10 -1
View File
@@ -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)
+122 -18
View File
@@ -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
+18 -3
View File
@@ -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",
+2
View File
@@ -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",
+201
View File
@@ -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:
+16
View File
@@ -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}),
+140 -18
View File
@@ -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 />