feat(ui): structured editors for script schemas and alarm triggers

Replace raw-JSON text inputs with rich UI: script parameter/return types use
a JSON Schema builder (SchemaBuilder + JsonSchemaShapeParser, with a migration
to convert existing definitions); alarm trigger config uses a type-aware
editor with a flattened attribute picker (AlarmTriggerEditor). AlarmActor
gains optional direction (rising/falling/either) on RateOfChange triggers.
This commit is contained in:
Joseph Doherty
2026-05-13 00:33:00 -04:00
parent 57f477fd28
commit 783da8e21a
25 changed files with 3609 additions and 861 deletions

View File

@@ -0,0 +1,204 @@
using System.Text.Json;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// In-memory JSON Schema tree used by <see cref="SchemaBuilder"/>. The editor
/// mutates this graph directly; <see cref="SchemaBuilderModel"/> handles
/// parse / serialize round-tripping to the canonical JSON Schema text stored
/// in TemplateScript / SharedScript / ApiMethod columns.
/// </summary>
internal sealed class SchemaNode
{
/// <summary>One of: <c>string · integer · number · boolean · object · array</c>.</summary>
public string Type { get; set; } = "string";
/// <summary>For <c>type=array</c>: the schema of the array's items.</summary>
public SchemaNode? Items { get; set; }
/// <summary>For <c>type=object</c>: ordered list of named properties.</summary>
public List<SchemaProperty> Properties { get; } = new();
}
internal sealed class SchemaProperty
{
/// <summary>Stable identity for Blazor <c>@key</c> across renames.</summary>
public Guid Id { get; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public bool Required { get; set; } = true;
public SchemaNode Schema { get; set; } = new();
}
internal static class SchemaBuilderModel
{
public static readonly string[] PrimitiveTypes =
{ "string", "integer", "number", "boolean", "object", "array" };
/// <summary>
/// Parse a JSON Schema string into a <see cref="SchemaNode"/> tree.
/// Returns the supplied <paramref name="fallback"/> when the input is
/// empty or malformed. Also accepts the legacy flat-array parameter
/// shape (<c>[{name,type,required,itemType?}]</c>) for safety during the
/// transition window — translates it into an equivalent object schema.
/// </summary>
public static SchemaNode Parse(string? json, SchemaNode fallback)
{
if (string.IsNullOrWhiteSpace(json)) return fallback;
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.ValueKind switch
{
JsonValueKind.Object => ParseSchema(doc.RootElement),
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
_ => fallback,
};
}
catch
{
return fallback;
}
}
/// <summary>Default empty object schema (parameters mode default).</summary>
public static SchemaNode NewObject() => new() { Type = "object" };
/// <summary>Default scalar schema (return mode default).</summary>
public static SchemaNode NewValue() => new() { Type = "string" };
public static string Serialize(SchemaNode node)
{
using var stream = new System.IO.MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
WriteNode(writer, node);
}
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
// ── Parse helpers ─────────────────────────────────────────────────────────
private static SchemaNode ParseSchema(JsonElement el)
{
var node = new SchemaNode { Type = "string" };
if (el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String)
{
node.Type = NormalizeType(t.GetString());
}
if (node.Type == "array")
{
node.Items = el.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object
? ParseSchema(items)
: new SchemaNode { Type = "string" };
}
else if (node.Type == "object")
{
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
if (el.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
{
foreach (var r in req.EnumerateArray())
{
if (r.ValueKind == JsonValueKind.String)
{
var s = r.GetString();
if (!string.IsNullOrEmpty(s)) requiredSet.Add(s);
}
}
}
if (el.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object)
{
foreach (var prop in props.EnumerateObject())
{
node.Properties.Add(new SchemaProperty
{
Name = prop.Name,
Required = requiredSet.Contains(prop.Name),
Schema = prop.Value.ValueKind == JsonValueKind.Object
? ParseSchema(prop.Value)
: new SchemaNode { Type = "string" },
});
}
}
}
return node;
}
private static SchemaNode ParseLegacyArray(JsonElement arr)
{
var root = new SchemaNode { Type = "object" };
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null;
if (string.IsNullOrEmpty(name)) continue;
var rawType = item.TryGetProperty("type", out var t) ? t.GetString() : "string";
var required = !item.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
var schema = new SchemaNode { Type = NormalizeType(rawType) };
if (schema.Type == "array")
{
var inner = item.TryGetProperty("itemType", out var it) ? it.GetString() : "string";
schema.Items = new SchemaNode { Type = NormalizeType(inner) };
}
root.Properties.Add(new SchemaProperty
{
Name = name,
Required = required,
Schema = schema,
});
}
return root;
}
private static string NormalizeType(string? raw) => raw?.ToLowerInvariant() switch
{
"boolean" or "bool" => "boolean",
"integer" or "int" or "int32" or "int64" => "integer",
"number" or "float" or "double" or "decimal" => "number",
"string" or "datetime" => "string",
"object" => "object",
"array" or "list" => "array",
_ => "string",
};
// ── Serialize helpers ─────────────────────────────────────────────────────
private static void WriteNode(Utf8JsonWriter w, SchemaNode node)
{
w.WriteStartObject();
w.WriteString("type", node.Type);
if (node.Type == "array")
{
w.WritePropertyName("items");
WriteNode(w, node.Items ?? new SchemaNode { Type = "string" });
}
else if (node.Type == "object")
{
w.WritePropertyName("properties");
w.WriteStartObject();
foreach (var p in node.Properties.Where(p => !string.IsNullOrWhiteSpace(p.Name)))
{
w.WritePropertyName(p.Name);
WriteNode(w, p.Schema);
}
w.WriteEndObject();
var required = node.Properties
.Where(p => p.Required && !string.IsNullOrWhiteSpace(p.Name))
.Select(p => p.Name)
.ToArray();
if (required.Length > 0)
{
w.WritePropertyName("required");
w.WriteStartArray();
foreach (var r in required) w.WriteStringValue(r);
w.WriteEndArray();
}
}
w.WriteEndObject();
}
}