feat: folder hierarchy for templates page (split-pane tree) #1
Reference in New Issue
Block a user
Delete Branch "feature/templates-folder-hierarchy"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
This branch grew well beyond its original "templates folder hierarchy" scope into a broad refactor + feature batch. Updating the description so reviewers can navigate it. No commits have been moved — only the description is being honest about what's actually here.
Headline: 122 commits, 549 files changed, +105k / -46k lines.
Topical breakdown
1. Templates folder hierarchy (original scope)
TemplateFolderentity + EF migration + repository + service (Create / Rename / Move with cycle detection / Delete-blocked-when-non-empty)./design/templatesrewritten as a split-pane tree (folders / templates / composition leaves) with per-kind context menus, folder-picker move modal, native HTML5 drag-drop, deep-link reveal./design/templates/{id}to follow the TreeView design guide.2. Derive-on-compose for templates (phases 1–9)
IsDerived+OwnerCompositionIdcolumns on Template; existing compositions migrated to derived templates.3. OPC UA endpoint config refactor
OpcUaEndpointConfigPOCO +OpcUaEndpointConfigSerializer(legacy fallback + flat-dict interop) +OpcUaEndpointConfigValidator.Dictionary<string,string>.OpcUaGlobalOptionsfor app-wide identity + cert paths.OpcUaEndpointEditorBlazor component with Authentication / Advanced / Deadband sections;DataConnectionFormuses it via a typed model.4. Scripts: Monaco + Roslyn editor
Call("MethodName", ...)etc.self/child/parentattribute and script accessors; scoped parent query + parent picker for multi-parent templates.5. Alarms
AlarmTriggerEditorused in the instance override modal.InstanceConfigure.6. UI audit cleanup (May 2026)
IDialogService+DialogHost.7. Health monitoring
8. Store-and-forward
9. Parked messages UI
10. API methods + API keys
11. Topology
/deployment/debug-viewwith the site + instance pre-selected and auto-connects.12. Removals
LmxProxyreference implementation and scrubbed its mentions from design plans.Design docs
docs/plans/2026-05-11-templates-folder-hierarchy-design.mddocs/plans/2026-05-11-templates-folder-hierarchy.mdSuggested review approach
Because this is so large, reviewing by topic (sections above) is more tractable than commit-by-commit. Each topic corresponds to a contiguous run of commits and the underlying entities are largely independent.
LdapMappings: flex header, search filter, per-row Edit + kebab Delete, @key, dropped Site-Scope-Rules cell in favor of a {n rule(s)} badge. LdapMappingForm: two stacked cards (Mapping then Site Scope Rules); scope rules render as removable chips with an inline "Add scope rule" form; create-mode disables the scope card with an explainer; role select gets form-text help. DataConnections: <h4> in flex header, Bulk actions dropdown holding Expand/Collapse, hover-visible kebab on tree nodes mirroring the right-click context menu, aria-labels, "No connections match the filter." inline empty state. DataConnectionForm: Site rendered as readonly plaintext + lock-after- creation note in edit mode; parallel Primary endpoint / Backup endpoint headings; "Optional" badge on Backup when null; form-text on FailoverRetryCount. ApiKeys: search filter, Status column dropped (state now lives in the kebab menu label "Disable"/"Enable"), Edit + kebab actions, @key, aria-labels. ApiKeyForm: nested card removed; fixed-text Back header; real clipboard copy via IJSRuntime + toast confirmation. Test selector fix in DataConnectionFormTests for the new Site readonly-plaintext rendering.New shared DiffDialog mirroring ConfirmDialog's API (ShowAsync(title, before, after)) so live-data pages stop hand-rolling Bootstrap modal markup. Topology: <h4> in flex header, aria-labels on Expand/Collapse/Refresh and the inline rename input, Live-updates toggle (suppresses the 15s timer when off), instance/area counts moved into a summary alert above the tree, Stale badge paired with bi-exclamation-triangle icon + aria-label, hand-rolled Diff modal replaced with <DiffDialog @ref>. Deployments: pause/resume auto-refresh button replaces the static "Auto-refresh: 10s" text; summary cards switch to col-lg-3 col-md-6 col-12; InProgress spinner gets role="status" + aria-label; failed rows pick up a bi-x-circle icon next to the Status badge; Deployment ID + Revision folded into one {id}@{revision[..8]} cell; inline Error column collapses behind a per-row "View error" toggle; bare empty-state text upgraded to the centered muted block. DebugView: status-strip card at the top showing instance / connection state / last snapshot timestamp plus a "Start fresh" button when the page auto-reconnected from localStorage. Per-table filter input, scroll-lock toggle, Clear button, and a 200-row queue-style cap. <tbody> elements gain aria-live="polite" aria-atomic="false" for screen-reader announcements. Quality and Alarm-State badges get aria-labels; timestamps display HH:mm:ss with full ms in a hover tooltip. Auto-reconnect surfaces a toast with autoDismissMs: 8000.Dashboard: user-info card demoted; 4 KPI cards (Sites, Data connections, Templates, API keys) sourced from existing repositories; 3 Quick-action link cards (Health, Audit Log, Templates). Inline max-width style replaced with Bootstrap utilities. Health: KPI row condensed to Online / Offline / Sites with active errors (Total Sites and Total Script Errors dropped). Per-site cards re-laid out 2-column with each subsection (Data Connections, Instances & Queues, Errors & Parked Messages) inside Bootstrap collapse panels collapsed by default. Online / Offline / Primary / Standby badges paired with shape glyphs (o / * / triangle) plus aria-label. EventLogs: filter row wrapped in a Bootstrap collapse toggled by "Filter options (n active)"; per-row View toggle reveals the full message in a collapse row; "Keyword" relabeled "Message contains"; all filter inputs gain id+label-for+aria-label; severity badges paired with a leading glyph; explicit "End of results" terminator on Load more. ParkedMessages: Message ID rendered as <code>{first 12}...</code> plus a clipboard button; per-row View toggle reveals full error; action buttons get aria-label="{Retry|Discard} message {id}"; in-flight spinner inside the active button. AuditLog: pagination Next-disabled now uses _page * _pageSize >= _totalCount via HasMore helper (fixes the exactly-page-size edge case). Clear filters button added. Entity ID rendered as code + clipboard button. View/Hide buttons gain aria-label referencing the entry id. State JSON larger than 1 KB renders a "View in modal" button instead of the inline overflow.Two new shared components in Components/Shared: - ParameterListEditor: table of rows (name + type + item type + required + remove) - ReturnTypeEditor: single type (+ item type when List) Both round-trip the same JSON shape already stored on the entity: parameters: [{"name":"x","type":"String","required":true},...] return: {"type":"List","itemType":"Integer"} | null Type set follows the Inbound API validator (Boolean, Integer, Float, String, Object, List). Legacy values normalize on read — Int32 / int64 / Double / Decimal / lowercase string / etc all coalesce to the new set so existing rows render correctly. Re-saving persists the normalized form. Applied to: - SharedScriptForm - TemplateEdit Add Script form (also surfaces ParameterDefinitions + ReturnDefinition which the entity supported but the form was never wiring through) - ApiMethodForm Graceful degradation: invalid JSON is shown with a "Start fresh" escape hatch instead of crashing the form.Editors now set a _normalized flag when ParseFromJson coalesces a legacy type name (lowercase "string", "Int32", "Double", etc.) to the canonical set. When flagged, render a small alert-info inline: "Some parameter types were normalized... Save to persist the canonical form." The flag clears on any user edit so the notice doesn't linger after Emit overwrites the JSON. 31 new bUnit tests in tests/.../Shared/: - ParameterListEditorTests: null/empty rendering, row count per JSON entry, legacy type normalization across .NET names + lowercase, the normalized notice trigger, add/remove emission, List/non-List item-type column visibility, required-flag round trip, invalid JSON + non-array error paths. - ReturnTypeEditorTests: null vs simple vs List shape, legacy type normalization, change-type / clear-type emission, invalid JSON + non-object error paths. Total CentralUI test count: 82 -> 113.Adds Microsoft.CodeAnalysis.CSharp.Scripting (4.13.0). Scripts are compiled as C# script fragments against a ScriptHost globals type that mirrors what the runtime exposes (Parameters bag, CallShared, CallScript) — Roslyn reads the signatures so those identifiers are in scope for analysis without executing anything. ScriptAnalysisService: - Diagnose(code): Compilation.GetDiagnostics() projected to Monaco-shaped DiagnosticMarker records (severity 8/4/2/1). - Complete(code, line, col): dot-member lookup via SemanticModel when the token at position is part of a MemberAccessExpression; falls back to LookupSymbols at position for the general case. Two endpoints exposed by the existing CentralUI endpoint pipeline, both behind RequireDesign policy: POST /api/script-analysis/diagnostics POST /api/script-analysis/completions monaco-init.js registers a csharp CompletionItemProvider with dot/ paren/quote trigger chars, plus a 500 ms debounced diagnostics pass on every keystroke that pushes markers via setModelMarkers. Initial pass fires on editor create so existing scripts surface errors right away. Auth uses the existing cookie via credentials: same-origin. Smoke-verified: - Typing `DateTimeOffset.UtcNow` (no semicolon) shows the missing semicolon squiggle in real time. - Ctrl-Space at file scope returns the full type universe (AccessViolationException, Action, Akka, AppDomain, ...). Wave 2 of three. SCADA-specific extensions (declared param keys, shared/sibling script names, forbidden-API diagnostic) follow.Wave 3 of the Monaco/Roslyn integration. Adds the four extensions agreed in the design Q&A: 1. Parameters["..."] keys — when the cursor is inside a string literal that's the index of a Parameters[] element-access, completions return the parameter names declared in the form's ParameterListEditor. 2. CallShared("...") names — when the cursor is inside a string literal argument to a CallShared(...) invocation, completions return the names of all shared scripts (resolved server-side via SharedScriptService). 3. CallScript("...") names — same shape, but uses sibling-script names passed from the form (TemplateEdit's _scripts list). 4. Forbidden-API diagnostic — squiggles uses of the documented script trust model bans: System.IO / Diagnostics / Reflection / Net / Threading.Thread namespaces, plus the named types File, Directory, Process, Thread, Socket, etc. New diagnostic codes SCADA001 (using directive) and SCADA002 (type identifier). ScriptAnalysisService gains a SharedScriptService dependency (scoped, hence the analyzer is now scoped too); CompletionsRequest carries DeclaredParameters and SiblingScripts; Complete is now async. MonacoEditor.razor exposes DeclaredParameters / SiblingScripts parameters plus a [JSInvokable] GetContext() so the JS side asks for the latest form state on every completion request. The provider in monaco-init.js looks up the owning editor from the internal editors map and forwards the context. ScriptParameterNames helper parses the ParameterListEditor JSON into a name list — used by SharedScriptForm, ApiMethodForm, and TemplateEdit's Add-Script form to populate the Monaco context. Smoke-verified via direct fetch + Monaco trigger: - var x = Parameters[" → popup: "name" (declared parameter) - var y = CallShared(" → popup: GetWeather, Greet - using System.IO; → SCADA001 squiggle - Process.Start(...) → SCADA002 squiggle - File.ReadAllText(...) → SCADA002 squiggle Also fixed: ScriptAnalysisService scoped (was singleton, broke DI because SharedScriptService is scoped); JS normalizes Pascal-case context keys from Blazor's record serialization to camel-case for the request body.Now that the form holds parameter + return shapes for declared parameters, sibling scripts (template Scripts tab), and shared scripts (via SharedScriptCatalog), the editor leverages them four ways: 1. Snippet expansion on accept. Picking a CallShared or CallScript completion inserts the full call template with tabstops, e.g. `Greet", ${1:name})`. The JS provider extends the completion range over Monaco's auto-closed `")` so the snippet replaces the closing pair cleanly. Items carry insertTextRules=4 (InsertAsSnippet) and a command to immediately trigger parameter hints after acceptance. 2. Hover info. Hovering the script name token inside CallShared("X") or CallScript("Y") shows a markdown tooltip with the call signature and return type. New endpoint POST /api/script-analysis/hover. 3. Signature help. Inside CallShared(...) / CallScript(...) Monaco shows the parameter strip with the active parameter highlighted. The service walks up from the cursor to the nearest enclosing InvocationExpression and resolves which argument index the cursor is on. New endpoint POST /api/script-analysis/signature-help. 4. Argument-count diagnostic (SCADA004) and unknown-Parameters-key diagnostic (SCADA003). The Diagnose pipeline now consults the declared parameters and sibling/shared shapes to flag: - Parameters["typo"] when "typo" isn't on the form (warn) - CallScript("Calc", 1) when Calc declares 2 required args (err) - CallShared("Greet", 1, 2, 3) when Greet declares 1 arg (err) Optional parameters relax the required-count bound. Contract changes: - ScriptShape / ParameterShape records - ISharedScriptCatalog.GetShapesAsync (replaces GetNamesAsync) - new HoverRequest/Response, SignatureHelpRequest/Response - CompletionsRequest.SiblingScripts: string[] -> ScriptShape[] - DiagnoseRequest gains DeclaredParameters + SiblingScripts - CompletionItem gains InsertTextRules (Monaco snippet rule) Form wiring: - TemplateEdit passes ScriptShapeParser.Parse(...) per sibling - MonacoEditor surfaces SiblingScripts: IReadOnlyList<ScriptShape> - GetContext returns shapes to JS on each completion/hover/sig request 10 new ScriptAnalysisServiceTests covering all four features plus optional-parameter edge cases. Existing tests updated for the contract changes. Total: 113 -> 139. Browser-verified via direct curl + Monaco marker readback: - SCADA003 squiggle on Parameters["typo"] - Snippet item Greet", ${1:name}) with insertTextRules=4 - Hover markdown shape signature - Signature help parameter stripThree more editor features rolled in: 1. Roslyn Format command. New POST /api/script-analysis/format runs Formatter.Format() from Microsoft.CodeAnalysis.CSharp.Workspaces on the parsed script tree. monaco-init.js registers a DocumentFormattingEditProvider so Ctrl/Cmd-Shift-F and the toolbar "Format" button both work. 2. Inlay hints with parameter names. New POST /api/script-analysis/inlay-hints walks CallShared / CallScript invocations and emits InlayHint records positioned at each argument with the matching parameter's name (e.g. "name:"). Ghost text appears via Monaco's InlayHintsProvider. 3. SCADA005 argument-type diagnostic. Literal type vs. declared parameter type check on every CallShared/CallScript argument. Float accepts Integer literals; Object/List accept anything; null only matches reference-ish types. Legacy lowercase types ("string" etc) from the DB are normalized to the canonical set before comparison so existing data doesn't false-negative. Non-literal args (variables, expressions) are skipped — out of scope for a cheap pass. 4. Parameters["name"] hover. Hover endpoint now also resolves Parameters["X"] element-access keys against the form's DeclaredParameterShapes and returns "parameter `name: String`"-style markdown. MonacoEditor surfaces the new DeclaredParameterShapes parameter; ScriptParameterNames gets a ParseShapes companion. 5. Problems panel. Bootstrap card under the editor listing every marker with severity badge, line number, message, and SCADA / CS code. Click a row to scroll the editor to that line and focus. JS now invokes OnMarkersChanged on the .NET side whenever setModelMarkers fires, so the panel stays in sync with the editor. 6. Editor toolbar. Small top-right strip on each editor with Format / Wrap / Minimap / Theme toggles. New MonacoBlazor.format, setEditorOption, and revealLine JS APIs back the buttons and the problems-panel scroll-to-line. Contracts: - FormatRequest / FormatResponse - InlayHintsRequest / InlayHintsResponse / InlayHint - HoverRequest.DeclaredParameters - MonacoEditor.DeclaredParameterShapes parameter - MonacoEditor.MarkersChanged callback - ScadaContext.DeclaredParameterShapes 10 new xUnit tests covering format, inlay hints, SCADA005 (string- expects-integer, integer-expects-string, float-accepts-integer, object-accepts-anything, non-literal-skipped), and Parameters key hover. Total: 139 -> 149. Microsoft.CodeAnalysis.CSharp.Workspaces 4.13.0 added to pull in Formatter and AdhocWorkspace. Browser-verified: typing `CallShared("Greet", 42)` now shows the "name:" inlay hint and a SCADA005 squiggle on `42`; Parameters["typo"] shows SCADA003 as before; the toolbar buttons all work.Phases 1+2 of the design at docs/plans/2026-05-12-script-scope-access-design.md. Adds ergonomic scope-aware accessors to compiled scripts. A script on a composed TempSensor reads its own attribute via Attributes["Temperature"]; reaches up to the parent via Parent.Attributes["SpeedRPM"]; invokes a child script via Children["TempSensor"].CallScript("Sample"). All resolve to the existing flat Instance.GetAttribute / SetAttribute / CallScript delegates by prepending the script's canonical path prefix. Runtime types (SiteRuntime.Scripts.ScopeAccessors): AttributeAccessor sync indexer + GetAsync / SetAsync CompositionAccessor Attributes + CallScript ChildrenAccessor Children["name"] => CompositionAccessor ScriptGlobals gains Scope, Attributes, Children, Parent properties. Sync indexer blocks on the Instance Actor Ask; explicit GetAsync / SetAsync are also available for callers that want to await. Plumbing: - Commons.Types.Scripts.ScriptScope record (SelfPath / ParentPath). - ResolvedScript.Scope (defaults to ScriptScope.Root for back-compat). - FlatteningService emits new ScriptScope(prefix, "") for each composed script so a script defined on TempSensor composed under a parent gets SelfPath = "TempSensor". - ScriptActor reads the Scope from its ResolvedScript and forwards it through ScriptExecutionActor into ScriptGlobals on each call. RevisionHashService not touched: the per-script canonical name already encodes the composition path, so any structural change already flips the hash. 10 new unit tests on the path arithmetic. Site/Template engine suites stay green (129 + 199). Editor surface (Phase 3: metadata fetch, Phase 4: completion + SCADA006 / SCADA007 diagnostics) follows in the next commits.Phase 1 of the design at docs/plans/2026-05-12-derive-on-compose-design.md. Additive schema only — no behavior changes. Existing data and code paths continue to work; subsequent phases will start writing the new fields. Template gains: IsDerived true when this row was auto-created to back a composition slot OwnerCompositionId back-ref to the owning TemplateComposition (plain int, not an EF nav property — managed by TemplateService for cascade-delete) TemplateAttribute / TemplateScript each gain: IsInherited row copied from base and not yet overridden; changes to the base flow downward LockedInDerived on a base, blocks derived from overriding; enforced at the service layer in later phases EF Core migration AddDerivedTemplateFields adds four columns: Templates.IsDerived bit NOT NULL DEFAULT 0 Templates.OwnerCompositionId int NULL TemplateAttributes.IsInherited bit NOT NULL DEFAULT 0 TemplateAttributes.LockedInDerived bit NOT NULL DEFAULT 0 TemplateScripts.IsInherited bit NOT NULL DEFAULT 0 TemplateScripts.LockedInDerived bit NOT NULL DEFAULT 0 Existing rows get the defaults. Tests across SiteRuntime / TemplateEngine / CentralUI suites stay green (129 / 199 / 159). Next: phase 2 — wire AddCompositionAsync to derive on compose for new compositions. Old data still flows the direct-reference path until phase 3's migration script.AddCompositionAsync creates a derived Template ("<parent>.<slot>") that inherits from the base via ParentTemplateId. Base attributes and scripts are copied with IsInherited=true so the derived template carries its own override-able rows. The composition row points at the derived template, and the derived's OwnerCompositionId back-refs the composition for cascade delete. DeleteCompositionAsync cascade-deletes the owned derived template. DeleteTemplateAsync blocks direct deletion of derived templates and distinguishes derivatives from regular children, listing slot owners ("'Pump' (as 'TempSensor')") in the error. Composing a derived template is rejected — only bases can be composed. Existing compositions still resolve until phase 3 migrates them.EF migration MigrateCompositionsToDerived. Aborts with a clear error if any '<parent>.<slot>' derived name would collide with an existing template. Otherwise it cursor-walks every TemplateComposition that still points at a non-derived template: 1. Insert a derived Template (name "<parent>.<slot>", ParentTemplateId=base, IsDerived=1, OwnerCompositionId=composition). 2. Copy base attributes / scripts into the derived row with IsInherited=1, LockedInDerived=0. 3. Repoint TemplateComposition.ComposedTemplateId at the new derived. Idempotent: only touches compositions whose target is IsDerived=0, so re-runs and freshly-created Phase 2 compositions are skipped. Down() reverses by repointing compositions back to derived.ParentTemplateId and dropping all derived templates (with cascade copy rows).FlatteningService now treats IsInherited rows as placeholders: when a derived template carries an inherited attribute or script, the live base value resolves through the ParentTemplateId chain instead of the (possibly stale) copy. An IsInherited=false row is a real override and wins as before. ValidateLockedInDerived runs once per chain (main + composed) and returns a flatten-time failure if a derived template overrides a base row that the base marked LockedInDerived. TemplateService.Update{Attribute,Script}Async reject mid-flight when a derived target tries to override a LockedInDerived base member, and now persist IsInherited/LockedInDerived from the proposed payload so the UI can flip override state or set base-locks via the same endpoints.Templates tree hides IsDerived templates by default. A "Show derived" form-switch in the page header toggles them into the listing so users can reach orphaned derived templates when they need to. TemplateEdit: - Banner on derived templates: links to the base + the composing owner / slot name pulled from OwnerCompositionId. - Attributes/Scripts tables grew a context-aware column: * On derived templates: a Source badge (Inherited / Override / Local) plus a 🔒 Base-locked badge when the base marks LockedInDerived. * On base templates: a switch that flips LockedInDerived through UpdateAttribute/UpdateScript. - Effective Value / Code now resolves from the base when an inherited row carries a stale snapshot — matches the flatten-time behavior so the UI doesn't lie. - Override / Revert-to-base actions added to the row kebab; delete is hidden on inherited rows (the base owns those).DeleteCompositionAsync only dropped the top-level derived template — the cascaded inner derived rows (created when composing a composite source) were left orphaned with dangling OwnerCompositionId references. Any subsequent attempt to recompose the same source hit the name-collision guard ('Motor Controller.Pump.TempSensor' already exists). New CascadeDeleteDerivedAsync walks each composition on the derived template, recursively removes the slot-owned child derived first, then the composition row, then the derived itself. Mirrors the recursive shape of CreateCascadedCompositionAsync.Adds a new HiLo alarm trigger type with four configurable setpoints (LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority, deadband (for hysteresis), and operator message. The site runtime emits AlarmStateChanged with an AlarmLevel field so consumers can differentiate warning vs critical bands. Plumbing: - new AlarmLevel enum + AlarmStateChanged.Level/Message init properties - AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting - AlarmTriggerConfigCodec extracted from the editor for testability - sitestream.proto carries level + message over gRPC - SemanticValidator enforces numeric attribute, setpoint ordering, non-negative deadband - on-trigger scripts get an Alarm global (Name/Level/Priority/Message) so notification routing can branch by severity - per-instance InstanceAlarmOverride entity + EF migration + flattening step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary types whole-replace - DebugView shows a Level badge + per-band message tooltip - App.razor auto-reloads on permanent Blazor circuit failure - docker/regen-proto.sh automates the proto regen workflow (the linux/arm64 protoc segfault means generated files are checked in for now)The Parked Messages page returned "Parked message handler not available" because no actor was ever registered for ParkedMessages, and Retry/Discard requests had no Receive at all (would have hit deadletters). On top of that, StoreAndForwardService.StartAsync() was never called anywhere, so the sf_messages SQLite table was never created and the retry timer never ran — silently breaking all of S&F. - New ParkedMessageHandlerActor bridges StoreAndForwardService.{Get,Retry,Discard} using the Sender→Task→PipeTo pattern already used in DeploymentManagerActor. - SiteCommunicationActor now routes ParkedMessageRetryRequest and ParkedMessageDiscardRequest the same way as the existing Query handler. - AkkaHostedService.RegisterSiteActors() resolves StoreAndForwardService, calls StartAsync() to create the schema and start the timer, then creates and registers the handler actor.The ApiMethod entity had an ApprovedApiKeyIds column and ApiKeyValidator read it, but no UI/CLI/seed code ever wrote to it. Result: any inbound POST /api/{method} was rejected with 403 "API key not approved for this method" regardless of which key was sent. Add an "Approved API Keys" subsection to the method form, between Timeout and Parameters: vertical list of checkboxes, one per ApiKey row (with a "Disabled" badge for disabled keys, and a link to /admin/api-keys when none exist). OnInitializedAsync loads all keys and parses the existing comma-separated IDs; Save() serializes the selected set back to the entity on both create and edit paths. Re-uses IInboundApiRepository.GetAllApiKeysAsync — no repo or migration changes needed.Triage was painful on the old layout: a lone Site dropdown sat on a sparse row, errors were truncated mid-sentence with a per-row View/Hide toggle that on expand pushed an unwrapped <pre> through the table and shoved the Actions column off-screen, all rows looked the same regardless of age or attempt count, and OriginInstance — which tells you which instance produced the failure — wasn't displayed at all even though the data was on the entity. This pass: - Adds a real filter bar: Site, Category, Target system, Origin instance, Age window, free-text search. Category/Target/Origin/Age/Search filter the loaded page client-side; Site still drives the server query (and changing site now auto-queries — one fewer click). - Replaces the in-table expansion with an Offcanvas detail drawer. Clicking a row slides in a side panel with full message ID + copy, category label, origin, attempts, both timestamps in relative + absolute form, the complete error (pre-wrap, scrollable), and big Retry / Discard buttons. The table never overflows. - Stacks Target + Method into one column (target in semibold, method small/muted below) and surfaces Origin as a code-styled chip in a new column ("—" muted when null). - Severity left-border on each row, derived client-side from AttemptCount/MaxAttempts and age of the last attempt: red when retries are exhausted and last attempt was in the past hour, amber when exhausted but stale, muted grey otherwise. - Mini attempt progress bar under the n/max count, red when fully exhausted and amber while partial. - Relative timestamps ("5m ago", "1h ago", "2d ago") with absolute UTC on hover via the title attribute — applies in both the table and the drawer. - Bulk select: header checkbox selects the filtered set, per-row checkboxes. When ≥1 selected, a sticky action strip slides in below the filter bar offering Retry selected / Discard selected with the usual confirm dialog. Toast reports per-item success/failure counts. - Summary line next to the title: "N parked · K target systems · oldest Xh ago" (and "(showing M of N)" when filters are active). - ParkedMessageEntry contract extended additively with MaxAttempts, Category, and OriginInstance so the UI has the data it needs for severity, the category filter, and the new column. - Bumped page size from 25 to 50 to better match the dense layout.Admins can now check/uncheck which API methods this key is approved to invoke directly on /admin/api-keys/{id}/edit, instead of having to bounce through the Design role's API method editor. Membership is diffed against the initial state and applied by mutating ApprovedApiKeyIds on each affected ApiMethod in the same SaveChangesAsync.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.