From 3207b748180e83d7d7045af72b4c4aeb81d9afcb Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 09:13:11 +0100 Subject: [PATCH 01/25] build: update package versions --- Directory.Packages.props | 58 +++++++++---------- .../GroundControl.AppHost.csproj | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6de6eb50..26968d9c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + @@ -17,47 +17,47 @@ - - - - - + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - + - + - + - + - + - - + + diff --git a/src/GroundControl.AppHost/GroundControl.AppHost.csproj b/src/GroundControl.AppHost/GroundControl.AppHost.csproj index 08014b81..3f10aedc 100644 --- a/src/GroundControl.AppHost/GroundControl.AppHost.csproj +++ b/src/GroundControl.AppHost/GroundControl.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe From 5d4e3aa99d121a979eac7a1902cd93a2bf226266 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 09:39:49 +0100 Subject: [PATCH 02/25] feat(tower): add group-create and template nav actions to config tree Group rows expose a "+" action that opens the entry modal with the prefix pre-filled. Inherited entry rows now render a "Template" link button in place of the empty action slot, restoring value-column alignment and giving a one-click jump to the source template. Entry value, type badge, and scope count now share a single flex row so the value flows left-aligned right after the type badge. --- .../tower/config/ConfigTreeView.tsx | 88 +++++++++++++------ .../components/tower/config/EntryModal.tsx | 9 +- 2 files changed, 65 insertions(+), 32 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx index bbaac8c8..dc033ae7 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx @@ -1,3 +1,4 @@ +import { Link } from '@tanstack/react-router'; import { ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Layers3, Folder, FolderOpen, Hash, Lock, Pencil, Plus } from 'lucide-react'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; @@ -43,7 +44,7 @@ export const ConfigTreeView = forwardRef>(() => new Set()); const [selectedEntryId, setSelectedEntryId] = useState(null); const [editingEntry, setEditingEntry] = useState(); - const [creating, setCreating] = useState(false); + const [creatingKey, setCreatingKey] = useState(null); const [internalFilter, setInternalFilter] = useState(''); const resolvedFilter = filter ?? internalFilter; const sourceById = useMemo(() => new Map(effective.entries.map((item) => [item.entry.id, item.source])), [effective.entries]); @@ -86,7 +87,7 @@ export const ConfigTreeView = forwardRef ({ collapseAll: () => setCollapsed(new Set(allPrefixes)), expandAll: () => setCollapsed(new Set()), - openCreate: () => setCreating(true), + openCreate: () => setCreatingKey(''), }), [allPrefixes]); return ( @@ -105,7 +106,7 @@ export const ConfigTreeView = forwardRef - + ) : null} @@ -117,6 +118,7 @@ export const ConfigTreeView = forwardRef - + !open && setCreatingKey(null)} open={creatingKey !== null} ownerId={owner.id} ownerType={ownerType} /> !open && setEditingEntry(undefined)} open={Boolean(editingEntry)} ownerId={owner.id} ownerType={ownerType} /> @@ -143,6 +145,7 @@ interface TreeRowProps { collapsed: Set; depth?: number; node: TreeNode; + onCreate: (initialKey: string) => void; onEdit: (entry: ConfigEntry) => void; onSelect: (id: string) => void; selectedEntryId: null | string; @@ -150,33 +153,52 @@ interface TreeRowProps { sourceById: Map; } -function TreeRow({ collapsed, depth = 0, node, onEdit, onSelect, selectedEntryId, setCollapsed, sourceById }: TreeRowProps) { +function TreeRow({ collapsed, depth = 0, node, onCreate, onEdit, onSelect, selectedEntryId, setCollapsed, sourceById }: TreeRowProps) { if (node.kind === 'group') { const isCollapsed = collapsed.has(node.prefix); const segmentName = lastSegment(node.prefix); return (
- +
+ +
+ + + + + Add entry under {node.prefix} + +
+
{!isCollapsed ? node.children.map((child) => ( onSelect(node.entry.id)} @@ -209,19 +231,29 @@ function TreeRow({ collapsed, depth = 0, node, onEdit, onSelect, selectedEntryId : isInherited ?
); } diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx index 51bf275f..55864ec0 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx @@ -32,6 +32,7 @@ type EntryFormValues = z.infer; interface EntryModalProps { entry?: ConfigEntry; + initialKey?: string; mode: 'create' | 'edit'; onOpenChange: (open: boolean) => void; open: boolean; @@ -40,12 +41,12 @@ interface EntryModalProps { projectId?: string; } -export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType = 1, projectId }: EntryModalProps) { +export function EntryModal({ entry, initialKey, mode, onOpenChange, open, ownerId, ownerType = 1, projectId }: EntryModalProps) { const resolvedOwnerId = ownerId ?? projectId ?? ''; const createEntry = useCreateEntry(resolvedOwnerId, ownerType); const updateEntry = useUpdateEntry(resolvedOwnerId, ownerType); const [confirmingDelete, setConfirmingDelete] = useState(false); - const formValues = useMemo(() => toFormValues(entry), [entry]); + const formValues = useMemo(() => toFormValues(entry, initialKey), [entry, initialKey]); const form = useForm({ defaultValues: formValues, resolver: zodResolver(entrySchema), @@ -222,7 +223,7 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType ); } -function toFormValues(entry?: ConfigEntry): EntryFormValues { +function toFormValues(entry?: ConfigEntry, initialKey?: string): EntryFormValues { const defaultScopedValue = entry?.values.find((value) => !value.scopes || Object.keys(value.scopes).length === 0); const scopedValues = entry?.values.filter((value) => value !== defaultScopedValue).map((value) => { const [dimension = '', scopeValue = ''] = Object.entries(value.scopes ?? {})[0] ?? []; @@ -234,7 +235,7 @@ function toFormValues(entry?: ConfigEntry): EntryFormValues { defaultValue: defaultScopedValue?.value ?? '', description: entry?.description ?? '', isSensitive: entry?.isSensitive ?? false, - key: entry?.key ?? '', + key: entry?.key ?? initialKey ?? '', scopedValues, type: normalizeType(entry?.valueType), }; From d511a0630559df793573b874d7b0f5c5f7dc746a Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 11:31:17 +0100 Subject: [PATCH 03/25] feat(api): allow editing config entry key on update Adds a required Key field to UpdateConfigEntryRequest with the same shape validation as create. The handler captures the previous key for audit, applies the rename, and translates a unique-index collision from the persistence store into a 409 Conflict mirroring the create-side duplicate handling. The MongoDB store now persists Key on update and lifts MongoWriteException(DuplicateKey) into DuplicateKeyException. This is a breaking API change: callers that previously omitted key on PUT will now receive 400. --- .../Generated/Contracts/Contracts.cs | 427 ++++++++++++--- .../Generated/GroundControlApiClient.cs | 512 ++++++++++++++++-- .../Contracts/UpdateConfigEntryRequest.cs | 10 + .../ConfigEntries/UpdateConfigEntryHandler.cs | 17 +- .../UpdateConfigEntryValidator.cs | 5 + src/GroundControl.Api/OpenApi.json | 12 +- .../Stores/ConfigEntryStore.cs | 14 +- .../ConfigEntries/ConfigEntriesClientTests.cs | 1 + .../Audit/AuditRecordingTests.cs | 1 + .../ConfigEntriesHandlerTests.cs | 112 ++++ .../SensitiveSourcePersistenceTests.cs | 4 + .../Snapshots/SnapshotResolverTests.cs | 1 + .../Snapshots/SnapshotsHandlerTests.cs | 1 + .../Scenarios/PollingModeDeliveryWorkflow.cs | 1 + .../Scenarios/SnapshotRollbackWorkflow.cs | 1 + .../Scenarios/SseRealtimeConfigWorkflow.cs | 1 + .../ConfigEntries/ConfigEntryStoreTests.cs | 93 ++++ 17 files changed, 1069 insertions(+), 144 deletions(-) create mode 100644 tests/GroundControl.Persistence.MongoDb.Tests/ConfigEntries/ConfigEntryStoreTests.cs diff --git a/src/GroundControl.Api.Client/Generated/Contracts/Contracts.cs b/src/GroundControl.Api.Client/Generated/Contracts/Contracts.cs index 3aba71fd..8b260d64 100644 --- a/src/GroundControl.Api.Client/Generated/Contracts/Contracts.cs +++ b/src/GroundControl.Api.Client/Generated/Contracts/Contracts.cs @@ -1,6 +1,6 @@ //---------------------- // -// Generated using the NSwag toolchain v14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) // //---------------------- @@ -26,7 +26,7 @@ namespace GroundControl.Api.Client.Contracts { using System = global::System; - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial interface IGroundControlClient { @@ -63,6 +63,28 @@ public partial interface IGroundControlClient /// A server side error occurred. System.Threading.Tasks.Task ClientHealthHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get live activity summary + /// + /// + /// Returns the current live client count and activity event rate. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task GetActivitySummaryHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Stream live activity + /// + /// + /// Opens a Server-Sent Events stream that emits live Tower activity telemetry. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task StreamActivityHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// List audit records @@ -103,14 +125,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of clients for the specified project. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListClientsHandlerAsync(System.Guid projectId, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListClientsHandlerAsync(System.Guid projectId, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -163,14 +185,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of configuration entries. Optionally decrypts sensitive values. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListConfigEntriesHandlerAsync(System.Guid? ownerId = null, ConfigEntryOwnerType? ownerType = null, string? keyPrefix = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListConfigEntriesHandlerAsync(System.Guid? ownerId = null, ConfigEntryOwnerType? ownerType = null, string? keyPrefix = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -188,7 +210,7 @@ public partial interface IGroundControlClient /// Update a configuration entry /// /// - /// Updates an existing configuration entry. Requires an If-Match header with the current ETag value. + /// Updates an existing configuration entry, including its key. Requires an If-Match header with the current ETag value. /// /// OK /// A server side error occurred. @@ -223,14 +245,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of user groups. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListGroupsHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListGroupsHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -360,14 +382,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of projects. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListProjectsHandlerAsync(System.Guid? groupId = null, string? search = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListProjectsHandlerAsync(System.Guid? groupId = null, bool? ungrouped = null, string? search = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -402,6 +424,17 @@ public partial interface IGroundControlClient /// A server side error occurred. System.Threading.Tasks.Task DeleteProjectHandlerAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List projects grouped by owning group + /// + /// + /// Returns the first page of projects for every group plus an ungrouped bucket, all sorted by name ascending. Sections whose project list is empty after applying the search filter are omitted. Use the returned per-section cursor with GET /api/projects to fetch the next page. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ListGroupedProjectsHandlerAsync(string? search = null, int? perGroup = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Add a template to a project @@ -497,14 +530,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of scope definitions. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListScopesHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListScopesHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -557,14 +590,25 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of snapshots for the specified project. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListSnapshotsHandlerAsync(System.Guid projectId, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListSnapshotsHandlerAsync(System.Guid projectId, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Preview a snapshot + /// + /// + /// Resolves the project's current configuration into a snapshot-shaped payload without persisting it. Returns a diff hash that publish will use to detect drift. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task PreviewSnapshotHandlerAsync(System.Guid projectId, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -606,14 +650,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of configuration templates. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListTemplatesHandlerAsync(System.Guid? groupId = null, bool? globalOnly = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListTemplatesHandlerAsync(System.Guid? groupId = null, bool? globalOnly = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -666,14 +710,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of users. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListUsersHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListUsersHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -737,14 +781,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of variables. Optionally decrypts sensitive values. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListVariablesHandlerAsync(VariableScope? scope = null, System.Guid? groupId = null, System.Guid? projectId = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListVariablesHandlerAsync(VariableScope? scope = null, System.Guid? groupId = null, System.Guid? projectId = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -781,7 +825,28 @@ public partial interface IGroundControlClient } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record ActivitySummaryResponse + { + + [System.Text.Json.Serialization.JsonPropertyName("clients")] + public int Clients { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("rate")] + public double Rate { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record AuditRecordResponse { @@ -826,7 +891,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for changing a user's password. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ChangePasswordRequest { @@ -856,7 +921,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the resolved configuration payload returned to an authenticated client. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ClientConfigResponse { @@ -892,7 +957,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a client. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ClientResponse { @@ -982,7 +1047,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Defines the owner type for a configuration entry. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public enum ConfigEntryOwnerType { @@ -995,7 +1060,7 @@ public enum ConfigEntryOwnerType /// /// Represents the API response body for a configuration entry. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ConfigEntryResponse { @@ -1092,7 +1157,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a single resolved configuration value returned to an authenticated client, including a flag that marks it as sensitive. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ConfigValue { @@ -1122,7 +1187,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a client. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateClientRequest { @@ -1158,7 +1223,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a newly created client, including the raw secret. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateClientResponse { @@ -1236,12 +1301,13 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a configuration entry. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateConfigEntryRequest { /// - /// Gets the configuration key. + /// Gets the configuration key. Must start with a letter and contain only letters, digits, or + ///
the separators `.`, `:`, `_`, `-`. ///
[System.Text.Json.Serialization.JsonPropertyName("key")] public string Key { get; set; } = default!; @@ -1296,7 +1362,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a group. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateGroupRequest { @@ -1323,7 +1389,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreatePatRequest { @@ -1356,7 +1422,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreatePatResponse { @@ -1395,7 +1461,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a project. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateProjectRequest { @@ -1437,7 +1503,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a role. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateRoleRequest { @@ -1473,7 +1539,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a scope definition. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateScopeRequest { @@ -1509,7 +1575,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a template. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateTemplateRequest { @@ -1545,7 +1611,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a user. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateUserRequest { @@ -1584,7 +1650,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateVariableRequest { @@ -1620,7 +1686,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record FieldChangeResponse { @@ -1647,7 +1713,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a role grant in request and response contracts. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record GrantDto { @@ -1680,10 +1746,93 @@ public System.Collections.Generic.IDictionary AdditionalProperti } + /// + /// Represents a project listing partitioned by owning group, with a separate bucket for ungrouped projects. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record GroupedProjectsResponse + { + + /// + /// Gets the per-group sections, sorted by group name ascending. Sections whose project list is empty + ///
after applying the search filter are omitted. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("groups")] + public System.Collections.Generic.ICollection Groups { get; set; } = new System.Collections.ObjectModel.Collection(); + + [System.Text.Json.Serialization.JsonPropertyName("ungrouped")] + public UngroupedProjects? Ungrouped { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// Represents a single group section within a grouped project listing. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record GroupProjects + { + + /// + /// Gets the group identifier. + /// + [System.Text.Json.Serialization.JsonPropertyName("id")] + public System.Guid Id { get; set; } = default!; + + /// + /// Gets the group display name. + /// + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } = default!; + + /// + /// Gets the optional group description. + /// + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string? Description { get; set; } = default!; + + /// + /// Gets the total number of projects in this group that match the current filter. + /// + [System.Text.Json.Serialization.JsonPropertyName("totalCount")] + public long TotalCount { get; set; } = default!; + + /// + /// Gets the first page of matching projects in this group, sorted by name ascending. + /// + [System.Text.Json.Serialization.JsonPropertyName("projects")] + public System.Collections.Generic.ICollection Projects { get; set; } = new System.Collections.ObjectModel.Collection(); + + /// + /// Gets the cursor used to fetch the next page via `GET /api/projects?groupId=&after=`, or + ///
`null` when no more pages exist. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("nextCursor")] + public string? NextCursor { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + /// /// Represents the API response body for a group. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record GroupResponse { @@ -1746,7 +1895,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record HttpValidationProblemDetails : ProblemDetails { @@ -1758,7 +1907,7 @@ public partial record HttpValidationProblemDetails : ProblemDetails /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfAuditRecordResponse { @@ -1800,7 +1949,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfClientResponse { @@ -1842,7 +1991,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfConfigEntryResponse { @@ -1884,7 +2033,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfGroupResponse { @@ -1926,7 +2075,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfProjectResponse { @@ -1968,7 +2117,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfScopeResponse { @@ -2010,7 +2159,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfSnapshotSummaryResponse { @@ -2052,7 +2201,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfTemplateResponse { @@ -2094,7 +2243,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfUserResponse { @@ -2136,7 +2285,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfVariableResponse { @@ -2175,7 +2324,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PatResponse { @@ -2214,7 +2363,61 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + /// + /// Represents the API response body for a snapshot preview. Mirrors the shape of a published + ///
snapshot so callers can diff the preview's IReadOnlyList<ResolvedEntryResponse> PreviewSnapshotResponse.Entries against an active snapshot's + ///
entries directly. + ///
+ [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record PreviewSnapshotResponse + { + + /// + /// Gets the project identifier the preview was computed against. + /// + [System.Text.Json.Serialization.JsonPropertyName("projectId")] + public System.Guid ProjectId { get; set; } = default!; + + /// + /// Gets the snapshot version that would be assigned if a publish call were made now. + /// + [System.Text.Json.Serialization.JsonPropertyName("nextVersion")] + public long NextVersion { get; set; } = default!; + + /// + /// Gets the BSON size of the would-be snapshot in bytes. Surfaces the same 16MB limit that + ///
publish would enforce so callers can fail fast. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("bsonSizeBytes")] + public long BsonSizeBytes { get; set; } = default!; + + /// + /// Gets a deterministic SHA-256 hex digest over the preview's resolved entries. Pass back to + ///
the publish endpoint as `expectedHash` to detect drift between preview and publish. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("diffHash")] + public string DiffHash { get; set; } = default!; + + /// + /// Gets the resolved entries that would be written to the snapshot, masking sensitive values + ///
unless the caller has the `SensitiveValuesDecrypt` permission and supplied + ///
`?decrypt=true`. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("entries")] + public System.Collections.Generic.ICollection Entries { get; set; } = new System.Collections.ObjectModel.Collection(); + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ProblemDetails { @@ -2247,7 +2450,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a project. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ProjectResponse { @@ -2331,7 +2534,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for publishing a new snapshot. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PublishSnapshotRequest { @@ -2341,6 +2544,14 @@ public partial record PublishSnapshotRequest [System.Text.Json.Serialization.JsonPropertyName("description")] public string? Description { get; set; } = default!; + /// + /// Gets the optional diff hash returned by a prior preview call. When supplied, the publish call + ///
fails with 409 if the resolved configuration's hash differs at publish time, indicating that + ///
the project was mutated since the preview was generated. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("expectedHash")] + public string? ExpectedHash { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -2355,7 +2566,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a resolved configuration entry in a snapshot response. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ResolvedEntryResponse { @@ -2397,7 +2608,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a role. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record RoleResponse { @@ -2469,7 +2680,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a value variant for a specific scope combination. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ScopedValue { @@ -2499,7 +2710,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a scope-specific value in a configuration entry request. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ScopedValueRequest { @@ -2529,7 +2740,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a scope-specific value in a snapshot response. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ScopedValueResponse { @@ -2559,7 +2770,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a scope definition. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ScopeResponse { @@ -2631,7 +2842,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for adding a user as a group member with a specific role. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record SetGroupMemberRequest { @@ -2655,7 +2866,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the full API response body for a snapshot, including resolved entries. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record SnapshotResponse { @@ -2715,7 +2926,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a summary API response body for a snapshot, without resolved entries. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record SnapshotSummaryResponse { @@ -2775,7 +2986,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a template. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record TemplateResponse { @@ -2844,10 +3055,47 @@ public System.Collections.Generic.IDictionary AdditionalProperti } + /// + /// Represents the bucket of projects that have no owning group. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record UngroupedProjects + { + + /// + /// Gets the total number of ungrouped projects that match the current filter. + /// + [System.Text.Json.Serialization.JsonPropertyName("totalCount")] + public long TotalCount { get; set; } = default!; + + /// + /// Gets the first page of matching ungrouped projects, sorted by name ascending. + /// + [System.Text.Json.Serialization.JsonPropertyName("projects")] + public System.Collections.Generic.ICollection Projects { get; set; } = new System.Collections.ObjectModel.Collection(); + + /// + /// Gets the cursor used to fetch the next page via `GET /api/projects?ungrouped=true&after=`, + ///
or `null` when no more pages exist. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("nextCursor")] + public string? NextCursor { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + /// /// Represents the request body for updating a client. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateClientRequest { @@ -2863,6 +3111,12 @@ public partial record UpdateClientRequest [System.Text.Json.Serialization.JsonPropertyName("isActive")] public bool IsActive { get; set; } = default!; + /// + /// Gets the fixed scope assignments for the client. When provided, replaces the existing scope context. + /// + [System.Text.Json.Serialization.JsonPropertyName("scopes")] + public System.Collections.Generic.IDictionary? Scopes { get; set; } = default!; + /// /// Gets the optional expiration timestamp. /// @@ -2883,10 +3137,17 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a configuration entry. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateConfigEntryRequest { + /// + /// Gets the configuration key. Must start with a letter and contain only letters, digits, or + ///
the separators `.`, `:`, `_`, `-`. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("key")] + public string Key { get; set; } = default!; + /// /// Gets the value type name. /// @@ -2925,7 +3186,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a group. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateGroupRequest { @@ -2955,7 +3216,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a project. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateProjectRequest { @@ -2997,7 +3258,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a role. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateRoleRequest { @@ -3033,7 +3294,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a scope definition. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateScopeRequest { @@ -3069,7 +3330,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a template. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateTemplateRequest { @@ -3105,7 +3366,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a user. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateUserRequest { @@ -3144,7 +3405,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateVariableRequest { @@ -3171,7 +3432,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a user. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UserResponse { @@ -3252,7 +3513,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record VariableResponse { @@ -3309,7 +3570,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Defines the ownership scope for a variable. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public enum VariableScope { @@ -3321,7 +3582,7 @@ public enum VariableScope - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GroundControlApiClientException : System.Exception { public int StatusCode { get; private set; } @@ -3344,7 +3605,7 @@ public override string ToString() } } - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GroundControlApiClientException : GroundControlApiClientException { public TResult Result { get; private set; } diff --git a/src/GroundControl.Api.Client/Generated/GroundControlApiClient.cs b/src/GroundControl.Api.Client/Generated/GroundControlApiClient.cs index 58a71077..6e24d228 100644 --- a/src/GroundControl.Api.Client/Generated/GroundControlApiClient.cs +++ b/src/GroundControl.Api.Client/Generated/GroundControlApiClient.cs @@ -1,6 +1,6 @@ //---------------------- // -// Generated using the NSwag toolchain v14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) // //---------------------- @@ -28,7 +28,7 @@ namespace GroundControl.Api.Client { using System = global::System; - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GroundControlClient : IGroundControlClient { private System.Net.Http.HttpClient _httpClient; @@ -315,6 +315,174 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() } } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get live activity summary + /// + /// + /// Returns the current live client count and activity event rate. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetActivitySummaryHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "activity/summary" + urlBuilder_.Append("activity/summary"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new GroundControlApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Stream live activity + /// + /// + /// Opens a Server-Sent Events stream that emits live Tower activity telemetry. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task StreamActivityHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "activity/stream" + urlBuilder_.Append("activity/stream"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 403) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Forbidden", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new GroundControlApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// List audit records @@ -633,14 +801,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of clients for the specified project. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListClientsHandlerAsync(System.Guid projectId, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListClientsHandlerAsync(System.Guid projectId, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (projectId == null) throw new System.ArgumentNullException("projectId"); @@ -661,10 +829,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(projectId, System.Globalization.CultureInfo.InvariantCulture))); urlBuilder_.Append("/clients"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -673,6 +837,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -915,6 +1083,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else if (status_ == 404) { var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); @@ -1185,14 +1363,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of configuration entries. Optionally decrypts sensitive values. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListConfigEntriesHandlerAsync(System.Guid? ownerId = null, ConfigEntryOwnerType? ownerType = null, string? keyPrefix = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListConfigEntriesHandlerAsync(System.Guid? ownerId = null, ConfigEntryOwnerType? ownerType = null, string? keyPrefix = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -1220,10 +1398,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("KeyPrefix")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(keyPrefix, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -1232,6 +1406,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -1410,7 +1588,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// Update a configuration entry /// /// - /// Updates an existing configuration entry. Requires an If-Match header with the current ETag value. + /// Updates an existing configuration entry, including its key. Requires an If-Match header with the current ETag value. /// /// OK /// A server side error occurred. @@ -1739,14 +1917,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of user groups. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListGroupsHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListGroupsHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -1762,10 +1940,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() // Operation Path: "api/groups" urlBuilder_.Append("api/groups"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -1774,6 +1948,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -2967,14 +3145,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of projects. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListProjectsHandlerAsync(System.Guid? groupId = null, string? search = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListProjectsHandlerAsync(System.Guid? groupId = null, bool? ungrouped = null, string? search = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -2994,13 +3172,13 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("GroupId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(groupId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (search != null) + if (ungrouped != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("Search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("Ungrouped")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(ungrouped, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (limit != null) + if (search != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("Search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } if (after != null) { @@ -3010,6 +3188,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -3406,6 +3588,103 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() } } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List projects grouped by owning group + /// + /// + /// Returns the first page of projects for every group plus an ungrouped bucket, all sorted by name ascending. Sections whose project list is empty after applying the search filter are omitted. Use the returned per-section cursor with GET /api/projects to fetch the next page. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ListGroupedProjectsHandlerAsync(string? search = null, int? perGroup = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/projects/grouped" + urlBuilder_.Append("api/projects/grouped"); + urlBuilder_.Append('?'); + if (search != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (perGroup != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PerGroup")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(perGroup, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new GroundControlApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Add a template to a project @@ -4235,14 +4514,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of scope definitions. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListScopesHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListScopesHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -4258,10 +4537,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() // Operation Path: "api/scopes" urlBuilder_.Append("api/scopes"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -4270,6 +4545,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -4746,6 +5025,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() throw new GroundControlApiClientException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); } else + if (status_ == 409) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Conflict", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else if (status_ == 422) { var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); @@ -4782,14 +5071,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of snapshots for the specified project. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListSnapshotsHandlerAsync(System.Guid projectId, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListSnapshotsHandlerAsync(System.Guid projectId, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (projectId == null) throw new System.ArgumentNullException("projectId"); @@ -4810,10 +5099,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(projectId, System.Globalization.CultureInfo.InvariantCulture))); urlBuilder_.Append("/snapshots"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -4822,6 +5107,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -4894,6 +5183,115 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() } } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Preview a snapshot + /// + /// + /// Resolves the project's current configuration into a snapshot-shaped payload without persisting it. Returns a diff hash that publish will use to detect drift. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task PreviewSnapshotHandlerAsync(System.Guid projectId, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (projectId == null) + throw new System.ArgumentNullException("projectId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/projects/{projectId}/snapshots/preview" + urlBuilder_.Append("api/projects/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(projectId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/snapshots/preview"); + urlBuilder_.Append('?'); + if (decrypt != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("decrypt")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(decrypt, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Unprocessable Entity", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new GroundControlApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Activate a snapshot @@ -5205,14 +5603,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of configuration templates. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListTemplatesHandlerAsync(System.Guid? groupId = null, bool? globalOnly = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListTemplatesHandlerAsync(System.Guid? groupId = null, bool? globalOnly = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -5236,10 +5634,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("GlobalOnly")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(globalOnly, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -5248,6 +5642,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -5755,14 +6153,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of users. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListUsersHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListUsersHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -5778,10 +6176,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() // Operation Path: "api/users" urlBuilder_.Append("api/users"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -5790,6 +6184,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -6430,14 +6828,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of variables. Optionally decrypts sensitive values. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListVariablesHandlerAsync(VariableScope? scope = null, System.Guid? groupId = null, System.Guid? projectId = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListVariablesHandlerAsync(VariableScope? scope = null, System.Guid? groupId = null, System.Guid? projectId = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -6465,10 +6863,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("ProjectId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(projectId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -6477,6 +6871,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); diff --git a/src/GroundControl.Api/Features/ConfigEntries/Contracts/UpdateConfigEntryRequest.cs b/src/GroundControl.Api/Features/ConfigEntries/Contracts/UpdateConfigEntryRequest.cs index 3812cc96..8584fe88 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/Contracts/UpdateConfigEntryRequest.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/Contracts/UpdateConfigEntryRequest.cs @@ -7,6 +7,16 @@ namespace GroundControl.Api.Features.ConfigEntries.Contracts; /// internal sealed record UpdateConfigEntryRequest { + /// + /// Gets the configuration key. Must start with a letter and contain only letters, digits, or + /// the separators ., :, _, -. + /// + /// Maximum length: 500 characters. + [Required] + [MaxLength(500)] + [RegularExpression(ConfigEntryValidation.KeyPattern, ErrorMessage = ConfigEntryValidation.KeyPatternErrorMessage)] + public required string Key { get; init; } + /// /// Gets the value type name. /// diff --git a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs index cafa758e..be5639fd 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs @@ -2,6 +2,7 @@ using GroundControl.Api.Shared.Audit; using GroundControl.Api.Shared.Security; using GroundControl.Api.Shared.Security.Protection; +using GroundControl.Persistence; using GroundControl.Persistence.Contracts; using GroundControl.Persistence.Stores; using Microsoft.AspNetCore.Mvc; @@ -57,6 +58,7 @@ private async Task HandleAsync(Guid id, UpdateConfigEntryRequest reques return problem; } + var oldKey = entry.Key; var oldValueType = entry.ValueType; var oldIsSensitive = entry.IsSensitive; var oldDescription = entry.Description; @@ -70,6 +72,7 @@ private async Task HandleAsync(Guid id, UpdateConfigEntryRequest reques var newPlaintextValues = request.Values.Select(v => new ScopedValue(v.Value, v.Scopes)).ToList(); var protectedValues = _protector.ProtectValues(newPlaintextValues, request.IsSensitive); + entry.Key = request.Key; entry.ValueType = request.ValueType; entry.Values.Clear(); foreach (var v in protectedValues) @@ -82,13 +85,25 @@ private async Task HandleAsync(Guid id, UpdateConfigEntryRequest reques entry.UpdatedAt = DateTimeOffset.UtcNow; entry.UpdatedBy = Guid.Empty; - var updated = await _store.UpdateAsync(entry, expectedVersion, cancellationToken).ConfigureAwait(false); + bool updated; + try + { + updated = await _store.UpdateAsync(entry, expectedVersion, cancellationToken).ConfigureAwait(false); + } + catch (DuplicateKeyException) + { + return TypedResults.Problem( + detail: $"A config entry with key '{request.Key}' already exists for this owner.", + statusCode: StatusCodes.Status409Conflict); + } + if (!updated) { return TypedResults.Problem(detail: "Version conflict.", statusCode: StatusCodes.Status409Conflict); } List changes = [ + .. AuditRecorder.CompareFields("Key", oldKey, entry.Key), .. AuditRecorder.CompareFields("ValueType", oldValueType, entry.ValueType), .. AuditRecorder.CompareCollections("Values", [.. oldPlaintextValues], newPlaintextValues, auditIsSensitive), .. AuditRecorder.CompareFields("IsSensitive", oldIsSensitive.ToString(), entry.IsSensitive.ToString()), diff --git a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryValidator.cs b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryValidator.cs index 48318ad6..eee55364 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryValidator.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryValidator.cs @@ -15,6 +15,11 @@ public UpdateConfigEntryValidator(IScopeStore scopeStore) public async Task ValidateAsync(UpdateConfigEntryRequest instance, ValidationContext context, CancellationToken cancellationToken = default) { + if (!ConfigEntryValidation.IsValidKey(instance.Key)) + { + return ValidatorResult.Fail(ConfigEntryValidation.KeyPatternErrorMessage, nameof(instance.Key)); + } + if (!ConfigEntryValidation.IsValidValueType(instance.ValueType)) { return ValidatorResult.Fail($"ValueType '{instance.ValueType}' is not supported.", nameof(instance.ValueType)); diff --git a/src/GroundControl.Api/OpenApi.json b/src/GroundControl.Api/OpenApi.json index b650dbdf..2dfc60f8 100644 --- a/src/GroundControl.Api/OpenApi.json +++ b/src/GroundControl.Api/OpenApi.json @@ -851,7 +851,7 @@ "ConfigEntries" ], "summary": "Update a configuration entry", - "description": "Updates an existing configuration entry. Requires an If-Match header with the current ETag value.", + "description": "Updates an existing configuration entry, including its key. Requires an If-Match header with the current ETag value.", "operationId": "UpdateConfigEntryHandler", "parameters": [ { @@ -4555,8 +4555,9 @@ "properties": { "key": { "maxLength": 500, + "pattern": "^[A-Za-z][A-Za-z0-9.:_-]*$", "type": "string", - "description": "Gets the configuration key." + "description": "Gets the configuration key. Must start with a letter and contain only letters, digits, or\r\nthe separators `.`, `:`, `_`, `-`." }, "ownerId": { "type": "string", @@ -6272,11 +6273,18 @@ }, "UpdateConfigEntryRequest": { "required": [ + "key", "valueType", "values" ], "type": "object", "properties": { + "key": { + "maxLength": 500, + "pattern": "^[A-Za-z][A-Za-z0-9.:_-]*$", + "type": "string", + "description": "Gets the configuration key. Must start with a letter and contain only letters, digits, or\r\nthe separators `.`, `:`, `_`, `-`." + }, "valueType": { "maxLength": 50, "type": "string", diff --git a/src/GroundControl.Persistence.MongoDb/Stores/ConfigEntryStore.cs b/src/GroundControl.Persistence.MongoDb/Stores/ConfigEntryStore.cs index d599064d..2e531a70 100644 --- a/src/GroundControl.Persistence.MongoDb/Stores/ConfigEntryStore.cs +++ b/src/GroundControl.Persistence.MongoDb/Stores/ConfigEntryStore.cs @@ -66,6 +66,7 @@ public async Task UpdateAsync(ConfigEntry entry, long expectedVersion, Can Builders.Filter.Eq(entity => entity.Version, expectedVersion)); var update = Builders.Update + .Set(entity => entity.Key, entry.Key) .Set(entity => entity.ValueType, entry.ValueType) .Set(entity => entity.Values, entry.Values) .Set(entity => entity.IsSensitive, entry.IsSensitive) @@ -74,7 +75,18 @@ public async Task UpdateAsync(ConfigEntry entry, long expectedVersion, Can .Set(entity => entity.UpdatedBy, entry.UpdatedBy) .Set(entity => entity.Version, nextVersion); - var result = await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false); + UpdateResult result; + try + { + result = await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + throw new DuplicateKeyException( + $"A config entry with key '{entry.Key}' already exists for this owner.", + ex); + } + if (result.ModifiedCount != 1) { return false; diff --git a/tests/GroundControl.Api.Client.Tests/ConfigEntries/ConfigEntriesClientTests.cs b/tests/GroundControl.Api.Client.Tests/ConfigEntries/ConfigEntriesClientTests.cs index 97f84abe..8a3244aa 100644 --- a/tests/GroundControl.Api.Client.Tests/ConfigEntries/ConfigEntriesClientTests.cs +++ b/tests/GroundControl.Api.Client.Tests/ConfigEntries/ConfigEntriesClientTests.cs @@ -123,6 +123,7 @@ public async Task UpdateConfigEntry_ModifiesValues() var updateRequest = new GroundControl.Api.Features.ConfigEntries.Contracts.UpdateConfigEntryRequest { + Key = created.Key, ValueType = "Int64", Values = [new GroundControl.Api.Features.ConfigEntries.Contracts.ScopedValueRequest { Value = "30" }], IsSensitive = true, diff --git a/tests/GroundControl.Api.Tests/Audit/AuditRecordingTests.cs b/tests/GroundControl.Api.Tests/Audit/AuditRecordingTests.cs index 684b16cf..7e156202 100644 --- a/tests/GroundControl.Api.Tests/Audit/AuditRecordingTests.cs +++ b/tests/GroundControl.Api.Tests/Audit/AuditRecordingTests.cs @@ -156,6 +156,7 @@ public async Task UpdateConfigEntry_WhenSensitive_MasksFieldChangeValues() updateRequest.Content = JsonContent.Create( new UpdateConfigEntryRequest { + Key = created!.Key, ValueType = "String", Values = [new ConfigEntryScopedValueRequest { Value = "secret-value-2", Scopes = [] }], IsSensitive = true, diff --git a/tests/GroundControl.Api.Tests/ConfigEntries/ConfigEntriesHandlerTests.cs b/tests/GroundControl.Api.Tests/ConfigEntries/ConfigEntriesHandlerTests.cs index cc257a8f..d795ff1d 100644 --- a/tests/GroundControl.Api.Tests/ConfigEntries/ConfigEntriesHandlerTests.cs +++ b/tests/GroundControl.Api.Tests/ConfigEntries/ConfigEntriesHandlerTests.cs @@ -388,6 +388,7 @@ public async Task PutConfigEntry_WithCorrectIfMatch_ReturnsUpdatedEntry() request.Content = JsonContent.Create( new UpdateConfigEntryRequest { + Key = created.Key, ValueType = "Int64", Values = [new ScopedValueRequest { Value = "42" }], IsSensitive = true, @@ -424,6 +425,7 @@ public async Task PutConfigEntry_WithStaleIfMatch_ReturnsConflict() request.Content = JsonContent.Create( new UpdateConfigEntryRequest { + Key = created.Key, ValueType = "String", Values = [new ScopedValueRequest { Value = "updated" }], }, @@ -442,6 +444,116 @@ public async Task PutConfigEntry_WithStaleIfMatch_ReturnsConflict() problem.Detail.ShouldContain("Version conflict"); } + [Fact] + public async Task PutConfigEntry_WithRenamedKey_ReturnsUpdatedEntryWithNewKey() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var template = await CreateTemplateAsync(apiClient, "Test Template", TestCancellationToken); + var created = await CreateConfigEntryAsync(apiClient, "OriginalKey", template.Id, TestCancellationToken); + var getResponse = await apiClient.GetAsync($"/api/config-entries/{created.Id}", TestCancellationToken); + var etag = getResponse.Headers.ETag?.ToString(); + + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/config-entries/{created.Id}"); + request.Content = JsonContent.Create( + new UpdateConfigEntryRequest + { + Key = "RenamedKey", + ValueType = created.ValueType, + Values = [new ScopedValueRequest { Value = "default" }], + }, + options: WebJsonSerializerOptions); + + request.Headers.TryAddWithoutValidation("If-Match", etag); + + // Act + var response = await apiClient.SendAsync(request, TestCancellationToken); + var entry = await ReadConfigEntryAsync(response, TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + entry.Key.ShouldBe("RenamedKey"); + entry.Version.ShouldBe(2); + } + + [Fact] + public async Task PutConfigEntry_WithRenamedKeyCollidingWithSiblingInSameOwner_ReturnsConflict() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var template = await CreateTemplateAsync(apiClient, "Test Template", TestCancellationToken); + await CreateConfigEntryAsync(apiClient, "ExistingKey", template.Id, TestCancellationToken); + var created = await CreateConfigEntryAsync(apiClient, "OriginalKey", template.Id, TestCancellationToken); + var getResponse = await apiClient.GetAsync($"/api/config-entries/{created.Id}", TestCancellationToken); + var etag = getResponse.Headers.ETag?.ToString(); + + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/config-entries/{created.Id}"); + request.Content = JsonContent.Create( + new UpdateConfigEntryRequest + { + Key = "ExistingKey", + ValueType = created.ValueType, + Values = [new ScopedValueRequest { Value = "default" }], + }, + options: WebJsonSerializerOptions); + + request.Headers.TryAddWithoutValidation("If-Match", etag); + + // Act + var response = await apiClient.SendAsync(request, TestCancellationToken); + var problem = await response.ReadProblemAsync(TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.Conflict); + problem.ShouldNotBeNull(); + problem.Detail.ShouldNotBeNull(); + problem.Detail.ShouldContain("already exists"); + } + + [Theory] + [InlineData("1StartsWithDigit")] + [InlineData("_startsWithUnderscore")] + [InlineData(".startsWithDot")] + [InlineData("-startsWithDash")] + [InlineData(":startsWithColon")] + [InlineData("contains spaces")] + [InlineData("contains/slash")] + [InlineData("contains$dollar")] + public async Task PutConfigEntry_WithInvalidKeyShape_ReturnsBadRequest(string key) + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var template = await CreateTemplateAsync(apiClient, "Test Template", TestCancellationToken); + var created = await CreateConfigEntryAsync(apiClient, "OriginalKey", template.Id, TestCancellationToken); + var getResponse = await apiClient.GetAsync($"/api/config-entries/{created.Id}", TestCancellationToken); + var etag = getResponse.Headers.ETag?.ToString(); + + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/config-entries/{created.Id}"); + request.Content = JsonContent.Create( + new UpdateConfigEntryRequest + { + Key = key, + ValueType = created.ValueType, + Values = [new ScopedValueRequest { Value = "default" }], + }, + options: WebJsonSerializerOptions); + + request.Headers.TryAddWithoutValidation("If-Match", etag); + + // Act + var response = await apiClient.SendAsync(request, TestCancellationToken); + var problem = await response.ReadValidationProblemAsync(TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + problem.ShouldNotBeNull(); + problem.Errors.ShouldContainKey("Key"); + problem.Errors["Key"].ShouldContain(e => e.Contains("Key must start with a letter")); + } + [Fact] public async Task DeleteConfigEntry_WithCorrectIfMatch_ReturnsNoContent() { diff --git a/tests/GroundControl.Api.Tests/Masking/SensitiveSourcePersistenceTests.cs b/tests/GroundControl.Api.Tests/Masking/SensitiveSourcePersistenceTests.cs index 18ecfb9b..b00afb1c 100644 --- a/tests/GroundControl.Api.Tests/Masking/SensitiveSourcePersistenceTests.cs +++ b/tests/GroundControl.Api.Tests/Masking/SensitiveSourcePersistenceTests.cs @@ -105,6 +105,7 @@ public async Task UpdateConfigEntry_Sensitive_ResponseValuesAreMasked() updateRequest.Content = JsonContent.Create( new UpdateConfigEntryRequest { + Key = created.Key, ValueType = "String", Values = [new ConfigEntryScopedValueRequest { Value = "rotated!" }], IsSensitive = true, @@ -278,6 +279,7 @@ public async Task UpdateConfigEntry_SensitiveValueUnchanged_DoesNotEmitValuesAud updateRequest.Content = JsonContent.Create( new UpdateConfigEntryRequest { + Key = created.Key, ValueType = "String", Values = [new ConfigEntryScopedValueRequest { Value = "s3cret!" }], IsSensitive = true, @@ -364,6 +366,7 @@ public async Task UpdateConfigEntry_Sensitive_RejectsMaskSentinelValue() updateRequest.Content = JsonContent.Create( new UpdateConfigEntryRequest { + Key = created.Key, ValueType = "String", Values = [new ConfigEntryScopedValueRequest { Value = "***" }], IsSensitive = true, @@ -440,6 +443,7 @@ public async Task UpdateConfigEntry_TransitionFromSensitiveToNonSensitive_Stores updateRequest.Content = JsonContent.Create( new UpdateConfigEntryRequest { + Key = created.Key, ValueType = "String", Values = [new ConfigEntryScopedValueRequest { Value = "now-public" }], IsSensitive = false, diff --git a/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs b/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs index 06de167c..644d8546 100644 --- a/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs +++ b/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs @@ -895,6 +895,7 @@ private static async Task UpdateConfigEntryAsync(HttpClient apiClient, Guid entr var update = new UpdateConfigEntryRequest { + Key = existing.Key, ValueType = existing.ValueType, Values = [new ScopedValueRequest { Value = value }], IsSensitive = existing.IsSensitive, diff --git a/tests/GroundControl.Api.Tests/Snapshots/SnapshotsHandlerTests.cs b/tests/GroundControl.Api.Tests/Snapshots/SnapshotsHandlerTests.cs index e2f9719b..3eb67459 100644 --- a/tests/GroundControl.Api.Tests/Snapshots/SnapshotsHandlerTests.cs +++ b/tests/GroundControl.Api.Tests/Snapshots/SnapshotsHandlerTests.cs @@ -348,6 +348,7 @@ public async Task PublishSnapshot_AfterEntryChange_RejectsStaleHash() { Content = JsonContent.Create(new UpdateConfigEntryRequest { + Key = entry.Key, ValueType = entry.ValueType, Values = [new ScopedValueRequest { Value = "Changed" }], IsSensitive = entry.IsSensitive, diff --git a/tests/GroundControl.E2E.Tests/Scenarios/PollingModeDeliveryWorkflow.cs b/tests/GroundControl.E2E.Tests/Scenarios/PollingModeDeliveryWorkflow.cs index 1c54b21d..95851464 100644 --- a/tests/GroundControl.E2E.Tests/Scenarios/PollingModeDeliveryWorkflow.cs +++ b/tests/GroundControl.E2E.Tests/Scenarios/PollingModeDeliveryWorkflow.cs @@ -140,6 +140,7 @@ await ApiClient.UpdateConfigEntryHandlerAsync( configEntryId, new UpdateConfigEntryRequest { + Key = "app:version", ValueType = "String", Values = { diff --git a/tests/GroundControl.E2E.Tests/Scenarios/SnapshotRollbackWorkflow.cs b/tests/GroundControl.E2E.Tests/Scenarios/SnapshotRollbackWorkflow.cs index 60b1abb5..ac30e1a7 100644 --- a/tests/GroundControl.E2E.Tests/Scenarios/SnapshotRollbackWorkflow.cs +++ b/tests/GroundControl.E2E.Tests/Scenarios/SnapshotRollbackWorkflow.cs @@ -94,6 +94,7 @@ public Task Step04_UpdateConfigEntryToV2() => RunStep(4, async () => var updateRequest = new UpdateConfigEntryRequest { + Key = "app:version", ValueType = "String", Values = { diff --git a/tests/GroundControl.E2E.Tests/Scenarios/SseRealtimeConfigWorkflow.cs b/tests/GroundControl.E2E.Tests/Scenarios/SseRealtimeConfigWorkflow.cs index dc03e8f5..826d8b70 100644 --- a/tests/GroundControl.E2E.Tests/Scenarios/SseRealtimeConfigWorkflow.cs +++ b/tests/GroundControl.E2E.Tests/Scenarios/SseRealtimeConfigWorkflow.cs @@ -144,6 +144,7 @@ await ApiClient.UpdateConfigEntryHandlerAsync( configEntryId, new UpdateConfigEntryRequest { + Key = "app:version", ValueType = "String", Values = { diff --git a/tests/GroundControl.Persistence.MongoDb.Tests/ConfigEntries/ConfigEntryStoreTests.cs b/tests/GroundControl.Persistence.MongoDb.Tests/ConfigEntries/ConfigEntryStoreTests.cs new file mode 100644 index 00000000..135712c7 --- /dev/null +++ b/tests/GroundControl.Persistence.MongoDb.Tests/ConfigEntries/ConfigEntryStoreTests.cs @@ -0,0 +1,93 @@ +using GroundControl.Persistence.Contracts; +using GroundControl.Persistence.MongoDb.Conventions; +using GroundControl.Persistence.MongoDb.Stores; +using GroundControl.Persistence.MongoDb.Tests.Infrastructure; +using Shouldly; +using Xunit; + +namespace GroundControl.Persistence.MongoDb.Tests.ConfigEntries; + +[Collection("MongoDB")] +public sealed class ConfigEntryStoreTests +{ + private readonly MongoFixture _mongoFixture; + + public ConfigEntryStoreTests(MongoFixture mongoFixture) + { + _mongoFixture = mongoFixture; + } + + [Fact] + public async Task UpdateAsync_RenamingKeyToSiblingKeyInSameOwner_ThrowsDuplicateKeyException() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var store = await CreateStoreAsync(cancellationToken); + var ownerId = Guid.CreateVersion7(); + + await store.CreateAsync(CreateEntry(ownerId, "ExistingKey"), cancellationToken); + var renaming = CreateEntry(ownerId, "OriginalKey"); + await store.CreateAsync(renaming, cancellationToken); + + renaming.Key = "ExistingKey"; + + // Act / Assert + await Should.ThrowAsync( + () => store.UpdateAsync(renaming, renaming.Version, cancellationToken)); + } + + [Fact] + public async Task UpdateAsync_RenamingKeyToUnusedKey_PersistsNewKeyAndIncrementsVersion() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var store = await CreateStoreAsync(cancellationToken); + var entry = CreateEntry(Guid.CreateVersion7(), "OriginalKey"); + await store.CreateAsync(entry, cancellationToken); + + entry.Key = "RenamedKey"; + + // Act + var updated = await store.UpdateAsync(entry, entry.Version, cancellationToken); + var refetched = await store.GetByIdAsync(entry.Id, cancellationToken); + + // Assert + updated.ShouldBeTrue(); + refetched.ShouldNotBeNull(); + refetched.Key.ShouldBe("RenamedKey"); + refetched.Version.ShouldBe(2); + } + + private async Task CreateStoreAsync(CancellationToken cancellationToken) + { + var database = _mongoFixture.CreateDatabase(); + var context = _mongoFixture.CreateContext(database); + var configuration = new ConfigEntryConfiguration(context); + + await configuration.ConfigureAsync(cancellationToken).ConfigureAwait(false); + + return new ConfigEntryStore(context); + } + + private static ConfigEntry CreateEntry(Guid ownerId, string key) + { + var timestamp = DateTimeOffset.UtcNow; + + return new ConfigEntry + { + Id = Guid.CreateVersion7(), + Key = key, + OwnerId = ownerId, + OwnerType = ConfigEntryOwnerType.Project, + ValueType = "String", + Values = [new ScopedValue { Value = "value" }], + IsSensitive = false, + Description = null, + Version = 1, + CreatedAt = timestamp, + CreatedBy = Guid.Empty, + UpdatedAt = timestamp, + UpdatedBy = Guid.Empty + }; + } +} \ No newline at end of file From 013c191c21feff1e10cd1582d2564065b1ec25f6 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 11:31:24 +0100 Subject: [PATCH 04/25] feat(cli): support renaming config entry key on update Adds a --key option to "config-entry update" that, when supplied, asks the API to rename the entry; omitting it keeps the existing key. The TUI ViewModel forwards the current key on edits so its updates remain compatible with the now-required Key contract. --- .../Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs | 3 +++ .../Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs | 1 + .../Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs | 2 ++ .../Features/Tui/ViewModels/ConfigEntryViewModel.cs | 1 + 4 files changed, 7 insertions(+) diff --git a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs index 92d640b8..7cc925b7 100644 --- a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs +++ b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs @@ -8,6 +8,7 @@ public UpdateConfigEntryCommand() : base("update", "Update a configuration entry") { var idArgument = new Argument("id") { Description = "The configuration entry ID" }; + var keyOption = new Option("--key") { Description = "The new configuration key. Must start with a letter and contain only letters, digits, '.', ':', '_', or '-'." }; var valueTypeOption = new Option("--value-type") { Description = "The new value type name. Allowed: String, Int32, Int64, Double, Decimal, Boolean, DateTime, DateTimeOffset, DateOnly, TimeOnly." }; var sensitiveOption = new Option("--sensitive") { Description = "Whether the entry contains sensitive data" }; var descriptionOption = new Option("--description") { Description = "The new description" }; @@ -20,6 +21,7 @@ public UpdateConfigEntryCommand() var versionOption = new Option("--version") { Description = "The expected version for optimistic concurrency" }; Arguments.Add(idArgument); + Options.Add(keyOption); Options.Add(valueTypeOption); Options.Add(sensitiveOption); Options.Add(descriptionOption); @@ -30,6 +32,7 @@ public UpdateConfigEntryCommand() ConfigureOptions((parseResult, options) => { options.Id = parseResult.GetValue(idArgument); + options.Key = parseResult.GetValue(keyOption); options.ValueType = parseResult.GetValue(valueTypeOption); options.Sensitive = parseResult.GetValue(sensitiveOption); options.Description = parseResult.GetValue(descriptionOption); diff --git a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs index ef7bab6b..dfa89b9c 100644 --- a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs +++ b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs @@ -86,6 +86,7 @@ public async Task HandleAsync(CancellationToken cancellationToken) var request = new UpdateConfigEntryRequest { + Key = _options.Key ?? existing.Key, ValueType = _options.ValueType ?? existing.ValueType, Values = scopedValues, IsSensitive = _options.Sensitive, diff --git a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs index 6fae54f7..f7f9247b 100644 --- a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs +++ b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs @@ -4,6 +4,8 @@ internal sealed class UpdateConfigEntryOptions { public Guid Id { get; set; } + public string? Key { get; set; } + public string? ValueType { get; set; } public bool? Sensitive { get; set; } diff --git a/src/GroundControl.Cli/Features/Tui/ViewModels/ConfigEntryViewModel.cs b/src/GroundControl.Cli/Features/Tui/ViewModels/ConfigEntryViewModel.cs index fa405d73..f7f76b4e 100644 --- a/src/GroundControl.Cli/Features/Tui/ViewModels/ConfigEntryViewModel.cs +++ b/src/GroundControl.Cli/Features/Tui/ViewModels/ConfigEntryViewModel.cs @@ -90,6 +90,7 @@ internal override async Task UpdateAsync(ConfigEntryResponse item, Dictionary Date: Fri, 15 May 2026 11:31:30 +0100 Subject: [PATCH 05/25] feat(tower): allow editing config entry key in entry modal Enables the Key input in edit mode and threads the value through to the PUT body. Tightens the Zod regex to match the backend pattern (leading letter required) so the UI rejects the same shapes the API rejects rather than surfacing a 400 round-trip. Adds vitest coverage for the rename path and the leading-non-letter rejection. --- src/GroundControl.Tower/src/api/types.ts | 12 +++- .../tower/config/EntryModal.test.tsx | 69 ++++++++++++++++++- .../components/tower/config/EntryModal.tsx | 7 +- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/GroundControl.Tower/src/api/types.ts b/src/GroundControl.Tower/src/api/types.ts index 2589882d..5df4e357 100644 --- a/src/GroundControl.Tower/src/api/types.ts +++ b/src/GroundControl.Tower/src/api/types.ts @@ -234,7 +234,7 @@ export interface paths { get: operations["GetConfigEntryHandler"]; /** * Update a configuration entry - * @description Updates an existing configuration entry. Requires an If-Match header with the current ETag value. + * @description Updates an existing configuration entry, including its key. Requires an If-Match header with the current ETag value. */ put: operations["UpdateConfigEntryHandler"]; post?: never; @@ -1084,7 +1084,10 @@ export interface components { }; /** @description Represents the request body for creating a configuration entry. */ CreateConfigEntryRequest: { - /** @description Gets the configuration key. */ + /** + * @description Gets the configuration key. Must start with a letter and contain only letters, digits, or + * the separators `.`, `:`, `_`, `-`. + */ key: string; /** * Format: uuid @@ -1817,6 +1820,11 @@ export interface components { }; /** @description Represents the request body for updating a configuration entry. */ UpdateConfigEntryRequest: { + /** + * @description Gets the configuration key. Must start with a letter and contain only letters, digits, or + * the separators `.`, `:`, `_`, `-`. + */ + key: string; /** @description Gets the value type name. */ valueType: string; /** @description Gets the scope-specific values. */ diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx index 39554b0f..0a933bbc 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EntryModal } from './EntryModal'; import type { ConfigEntry } from '@/queries/useConfigEntries'; @@ -10,13 +10,14 @@ vi.mock('@/queries/useScopes', () => ({ useScopes: () => ({ data: { data: [] } }), })); +const updateEntryMock = vi.fn(); vi.mock('@/queries/useConfigEntries', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useCreateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), useDeleteEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), - useUpdateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), + useUpdateEntry: () => ({ isPending: false, mutateAsync: updateEntryMock }), }; }); @@ -32,6 +33,25 @@ function renderWithClient(ui: ReactNode) { const SENSITIVE_MASK = '***'; +function buildEntry(overrides: Partial = {}): ConfigEntry { + return { + createdAt: '2026-01-01T00:00:00Z', + createdBy: '00000000-0000-0000-0000-000000000000', + description: null, + id: '33333333-3333-3333-3333-333333333333', + isSensitive: false, + key: 'App:ServiceName', + ownerId: '22222222-2222-2222-2222-222222222222', + ownerType: 1, + updatedAt: '2026-01-01T00:00:00Z', + updatedBy: '00000000-0000-0000-0000-000000000000', + valueType: 'String', + values: [{ scopes: {}, value: 'checkout-api' }], + version: '1', + ...overrides, + } as ConfigEntry; +} + function buildSensitiveEntry(): ConfigEntry { return { createdAt: '2026-01-01T00:00:00Z', @@ -51,6 +71,12 @@ function buildSensitiveEntry(): ConfigEntry { } describe('EntryModal', () => { + beforeEach(() => { + updateEntryMock.mockReset(); + updateEntryMock.mockResolvedValue(undefined); + getConfigEntryMock.mockReset(); + }); + it('rejects invalid key characters', async () => { const user = userEvent.setup(); @@ -59,7 +85,44 @@ describe('EntryModal', () => { await user.type(screen.getByLabelText('Key'), 'bad key!'); await user.click(screen.getByRole('button', { name: 'Create entry' })); - expect(await screen.findByText('Use letters, numbers, colons, dots, underscores, and hyphens only')).toBeInTheDocument(); + expect( + await screen.findByText('Key must start with a letter and contain only letters, digits, colons, dots, underscores, or hyphens'), + ).toBeInTheDocument(); + }); + + it('rejects keys that do not start with a letter', async () => { + const user = userEvent.setup(); + + renderWithClient(); + + await user.type(screen.getByLabelText('Key'), '1leading-digit'); + await user.click(screen.getByRole('button', { name: 'Create entry' })); + + expect( + await screen.findByText('Key must start with a letter and contain only letters, digits, colons, dots, underscores, or hyphens'), + ).toBeInTheDocument(); + }); + + it('submits the renamed key when editing an entry', async () => { + const user = userEvent.setup(); + + renderWithClient( + , + ); + + const keyInput = screen.getByLabelText('Key') as HTMLInputElement; + expect(keyInput).not.toBeDisabled(); + expect(keyInput.value).toBe('App:ServiceName'); + + await user.clear(keyInput); + await user.type(keyInput, 'App:RenamedServiceName'); + await user.click(screen.getByRole('button', { name: 'Save entry' })); + + await waitFor(() => expect(updateEntryMock).toHaveBeenCalledTimes(1)); + const [variables] = updateEntryMock.mock.calls[0]; + expect(variables.body.key).toBe('App:RenamedServiceName'); + expect(variables.id).toBe('33333333-3333-3333-3333-333333333333'); + expect(variables.version).toBe('1'); }); it('locks the form for masked sensitive entries until reveal succeeds', async () => { diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx index 55864ec0..b6fe1df8 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx @@ -23,7 +23,7 @@ const entrySchema = z.object({ defaultValue: z.string(), description: z.string().max(500, 'Use 500 characters or fewer').optional(), isSensitive: z.boolean(), - key: z.string().min(1, 'Entry key is required').regex(/^[a-zA-Z0-9.:_-]+$/, 'Use letters, numbers, colons, dots, underscores, and hyphens only'), + key: z.string().min(1, 'Entry key is required').regex(/^[a-zA-Z][a-zA-Z0-9.:_-]*$/, 'Key must start with a letter and contain only letters, digits, colons, dots, underscores, or hyphens'), scopedValues: z.array(z.object({ dimension: z.string().optional(), scopeValue: z.string().optional(), value: z.string() })), type: z.enum(valueTypes), }); @@ -110,7 +110,7 @@ export function EntryModal({ entry, initialKey, mode, onOpenChange, open, ownerI const body = toRequest(values); if (mode === 'create') { - await createEntry.mutateAsync({ ...body, key: values.key, ownerId: resolvedOwnerId, ownerType }); + await createEntry.mutateAsync({ ...body, ownerId: resolvedOwnerId, ownerType }); } else if (entry) { await updateEntry.mutateAsync({ body, id: entry.id, version: entry.version.toString() }); } @@ -130,7 +130,7 @@ export function EntryModal({ entry, initialKey, mode, onOpenChange, open, ownerI
- + {form.formState.errors.key ?

{form.formState.errors.key.message}

: null}
@@ -245,6 +245,7 @@ function toRequest(values: EntryFormValues) { return { description: values.description?.trim() ? values.description.trim() : null, isSensitive: values.isSensitive, + key: values.key, valueType: values.type, values: [ ...(values.defaultValue ? [{ scopes: {}, value: values.defaultValue }] : []), From c8d70112d7e558c17abf8c6ac7281e9d4c5c61fe Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 12:26:13 +0100 Subject: [PATCH 06/25] refactor(tower): rename ConfigFlatView to ConfigListView Renames the flat view in the project and template config tabs to "List" so the label matches the icon already in use. Bumps the persisted tweaks store to version 1 with a migration that rewrites the previous 'flat' value to 'list' on rehydrate, so returning users keep the selected view instead of falling back to the default. --- .../{ConfigFlatView.tsx => ConfigListView.tsx} | 6 +++--- .../src/routes/projects/$projectId/config.tsx | 16 ++++++++-------- .../src/routes/templates/$templateId.tsx | 6 +++--- src/GroundControl.Tower/src/store/tweaks.ts | 13 +++++++++++-- 4 files changed, 25 insertions(+), 16 deletions(-) rename src/GroundControl.Tower/src/components/tower/config/{ConfigFlatView.tsx => ConfigListView.tsx} (97%) diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx similarity index 97% rename from src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx rename to src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx index 701ad2c7..3a6d4f45 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx @@ -14,17 +14,17 @@ import { EntryModal } from './EntryModal'; const columnHelper = createColumnHelper(); -interface ConfigFlatViewProps { +interface ConfigListViewProps { controlsPlacement?: 'external' | 'internal'; owner: ConfigOwner; search?: string; } -export interface ConfigFlatViewHandle { +export interface ConfigListViewHandle { openCreate: () => void; } -export const ConfigFlatView = forwardRef(function ConfigFlatView( +export const ConfigListView = forwardRef(function ConfigListView( { controlsPlacement = 'internal', owner, search }, ref, ) { diff --git a/src/GroundControl.Tower/src/routes/projects/$projectId/config.tsx b/src/GroundControl.Tower/src/routes/projects/$projectId/config.tsx index 736a0e1d..2e79ad52 100644 --- a/src/GroundControl.Tower/src/routes/projects/$projectId/config.tsx +++ b/src/GroundControl.Tower/src/routes/projects/$projectId/config.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { Braces, ChevronsDown, ChevronsUp, FolderTree, List, Plus, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { ConfigFlatView, type ConfigFlatViewHandle } from '@/components/tower/config/ConfigFlatView'; +import { ConfigListView, type ConfigListViewHandle } from '@/components/tower/config/ConfigListView'; import { ConfigJsonView } from '@/components/tower/config/ConfigJsonView'; import { ConfigTreeView, type ConfigTreeViewHandle } from '@/components/tower/config/ConfigTreeView'; import { SegmentedControl } from '@/components/tower/data/SegmentedControl'; @@ -31,10 +31,10 @@ function ConfigRoute() { const configViewMode = useTweaksStore((state) => state.configViewMode); const setConfigViewMode = useTweaksStore((state) => state.setConfigViewMode); const [open, setOpen] = useState(false); - const [flatSearch, setFlatSearch] = useState(''); + const [listSearch, setListSearch] = useState(''); const [search, setSearch] = useState(''); const [treeFilter, setTreeFilter] = useState(''); - const flatViewRef = useRef(null); + const listViewRef = useRef(null); const inputRef = useRef(null); const treeViewRef = useRef(null); @@ -76,16 +76,16 @@ function ConfigRoute() { - {configViewMode === 'flat' ? ( + {configViewMode === 'list' ? ( <> - setFlatSearch(event.target.value)} placeholder="Filter entries…" value={flatSearch} /> - + setListSearch(event.target.value)} placeholder="Filter entries…" value={listSearch} /> + ) : null} {configViewMode === 'tree' ? ( @@ -178,7 +178,7 @@ function ConfigRoute() { ? : configViewMode === 'json' ? - : } + : } ); } diff --git a/src/GroundControl.Tower/src/routes/templates/$templateId.tsx b/src/GroundControl.Tower/src/routes/templates/$templateId.tsx index b1a47084..06aacc3e 100644 --- a/src/GroundControl.Tower/src/routes/templates/$templateId.tsx +++ b/src/GroundControl.Tower/src/routes/templates/$templateId.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { Textarea } from '@/components/ui/textarea'; -import { ConfigFlatView } from '@/components/tower/config/ConfigFlatView'; +import { ConfigListView } from '@/components/tower/config/ConfigListView'; import { ConfigJsonView } from '@/components/tower/config/ConfigJsonView'; import { ConfigTreeView } from '@/components/tower/config/ConfigTreeView'; import { SegmentedControl } from '@/components/tower/data/SegmentedControl'; @@ -100,7 +100,7 @@ function TemplateDetailRoute() { - {configViewMode === 'tree' ? : configViewMode === 'json' ? : } + {configViewMode === 'tree' ? : configViewMode === 'json' ? : } ({ id: project.id, name: project.name }))} /> diff --git a/src/GroundControl.Tower/src/store/tweaks.ts b/src/GroundControl.Tower/src/store/tweaks.ts index 5c5f301d..3a934159 100644 --- a/src/GroundControl.Tower/src/store/tweaks.ts +++ b/src/GroundControl.Tower/src/store/tweaks.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; export type Theme = 'light' | 'dark'; -export type ConfigViewMode = 'flat' | 'tree' | 'json'; +export type ConfigViewMode = 'list' | 'tree' | 'json'; export type SnapshotViewMode = 'diff' | 'json' | 'json-diff'; export type DiffLayout = 'inline' | 'split'; @@ -28,7 +28,7 @@ export const useTweaksStore = create()( persist( (set, get) => ({ applyToDocument: () => applyToDocument(get().theme), - configViewMode: 'flat', + configViewMode: 'list', diffLayout: 'inline', diffLineWrap: true, driftBannerVisible: true, @@ -48,6 +48,15 @@ export const useTweaksStore = create()( }), { name: 'tower.tweaks', + // v1: configViewMode 'flat' renamed to 'list'. + version: 1, + migrate: (persistedState, version) => { + if (version < 1 && persistedState && typeof persistedState === 'object' && (persistedState as { configViewMode?: string }).configViewMode === 'flat') { + (persistedState as { configViewMode: ConfigViewMode }).configViewMode = 'list'; + } + + return persistedState as TweaksState; + }, onRehydrateStorage: () => (state) => { state?.applyToDocument(); }, From ae480c721ee2e6aa3233d9e499f08c7d4ac63233 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 12:41:01 +0100 Subject: [PATCH 07/25] feat(tower): enable column resizing in config list view Wires up TanStack's column resizing on the list view: each column declares a default size and minSize, the table renders with table-fixed plus min-w-full so it stretches to fill the wrapper when the column sum is smaller and overflows with horizontal scroll when larger, and a thin grab handle on each header drives the live resize. The Owner column's badge now truncates with ellipsis (icon stays visible, full template name remains in the tooltip) so narrowing it no longer spills into the next column. --- .../tower/config/ConfigListView.tsx | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx index 3a6d4f45..185a783e 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx @@ -41,17 +41,17 @@ export const ConfigListView = forwardRef { const baseColumns = [ - columnHelper.accessor((row) => row.entry.key, { cell: (info) => {info.getValue()}, header: 'Key', id: 'key' }), - columnHelper.accessor((row) => row.entry.valueType, { cell: (info) => {info.getValue()}, header: 'Type', id: 'valueType' }), - columnHelper.display({ cell: (info) => , header: 'Default Value', id: 'defaultValue' }), - columnHelper.display({ cell: (info) => {scopeCount(info.row.original.entry)} scopes, header: 'Scopes', id: 'scopes' }), + columnHelper.accessor((row) => row.entry.key, { cell: (info) => {info.getValue()}, header: 'Key', id: 'key', size: 400, minSize: 120 }), + columnHelper.accessor((row) => row.entry.valueType, { cell: (info) => {info.getValue()}, header: 'Type', id: 'valueType', size: 110, minSize: 80 }), + columnHelper.display({ cell: (info) => , header: 'Default Value', id: 'defaultValue', size: 280, minSize: 120 }), + columnHelper.display({ cell: (info) => {scopeCount(info.row.original.entry)} scopes, header: 'Scopes', id: 'scopes', size: 110, minSize: 80 }), ]; if (owner.kind === 'project') { - baseColumns.push(columnHelper.display({ cell: (info) => , header: 'Owner', id: 'owner' })); + baseColumns.push(columnHelper.display({ cell: (info) => , header: 'Owner', id: 'owner', size: 170, minSize: 100 })); } - baseColumns.push(columnHelper.accessor((row) => row.entry.updatedAt, { cell: (info) => relativeDate(info.getValue()), header: 'Updated', id: 'updatedAt' })); + baseColumns.push(columnHelper.accessor((row) => row.entry.updatedAt, { cell: (info) => relativeDate(info.getValue()), header: 'Updated', id: 'updatedAt', size: 130, minSize: 90 })); baseColumns.push(columnHelper.display({ cell: (info) => { const item = info.row.original; @@ -65,13 +65,25 @@ export const ConfigListView = forwardRef ); }, + enableResizing: false, header: '', id: 'actions', + size: 80, })); return baseColumns; }, [owner.kind]); - const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting, state: { sorting } }); + const table = useReactTable({ + columnResizeMode: 'onChange', + columns, + data, + defaultColumn: { minSize: 60, size: 150 }, + enableColumnResizing: true, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting, + state: { sorting }, + }); useImperativeHandle(ref, () => ({ openCreate: () => setCreating(true), @@ -93,11 +105,28 @@ export const ConfigListView = forwardRef
- +
{table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())})} + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : ( +
{flexRender(header.column.columnDef.header, header.getContext())}
+ )} + {header.column.getCanResize() ? ( + + ) : null} +
+ ))}
))}
@@ -111,7 +140,11 @@ export const ConfigListView = forwardRef setEditingEntry(row.original.entry)} > - {row.getVisibleCells().map((cell) => {flexRender(cell.column.columnDef.cell, cell.getContext())})} + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} ); })} @@ -141,7 +174,12 @@ function OwnerBadge({ source }: { source: EntrySource }) { return ( - + + + + Inherited from {source.templateName} template @@ -151,7 +189,11 @@ function OwnerBadge({ source }: { source: EntrySource }) { return ( - Overrides · {source.templateName} + + + Overrides · {source.templateName} + + Project entry overrides {source.templateName} From eaf6ff55211f9b9bac8423a17fe836272d2d89c0 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 13:35:49 +0100 Subject: [PATCH 08/25] feat(api): normalize scope keys to canonical casing on config entry write Lookup each request scope key via IScopeStore and rewrite it to the stored Scope.Dimension casing before persisting, so legacy lowercase keys converge on the canonical form and clients can match by exact string. Unknown keys are kept verbatim. --- .../ConfigEntries/ConfigEntryValidation.cs | 43 ++++++++++++ .../ConfigEntries/CreateConfigEntryHandler.cs | 7 +- .../ConfigEntries/UpdateConfigEntryHandler.cs | 7 +- .../ConfigEntriesHandlerTests.cs | 68 +++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs b/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs index 2859ff44..7c1dc47f 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs @@ -89,4 +89,47 @@ internal static partial class ConfigEntryValidation return null; } + + /// + /// Rewrites each scope key in to match the canonical + /// casing returned by the store, so persisted entries always + /// use the same casing as the scope they reference. Lookups happen via + /// , which is case-insensitive by collation. + /// Keys whose dimension cannot be resolved are kept verbatim — validation should reject those + /// upstream. + /// + public static async Task> NormalizeScopeKeysAsync( + IReadOnlyList values, + IScopeStore scopeStore, + CancellationToken cancellationToken) + { + var canonicalNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + var result = new List(values.Count); + + foreach (var scopedValue in values) + { + if (scopedValue.Scopes.Count == 0) + { + result.Add(scopedValue); + continue; + } + + var normalizedScopes = new Dictionary(scopedValue.Scopes.Count); + foreach (var (key, value) in scopedValue.Scopes) + { + if (!canonicalNames.TryGetValue(key, out var canonical)) + { + var scope = await scopeStore.GetByDimensionAsync(key, cancellationToken).ConfigureAwait(false); + canonical = scope?.Dimension ?? key; + canonicalNames[key] = canonical; + } + + normalizedScopes[canonical] = value; + } + + result.Add(scopedValue with { Scopes = normalizedScopes }); + } + + return result; + } } \ No newline at end of file diff --git a/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryHandler.cs b/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryHandler.cs index 3e1dcd93..8548acb6 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryHandler.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryHandler.cs @@ -12,12 +12,14 @@ namespace GroundControl.Api.Features.ConfigEntries; internal sealed class CreateConfigEntryHandler : IEndpointHandler { private readonly IConfigEntryStore _store; + private readonly IScopeStore _scopeStore; private readonly AuditRecorder _audit; private readonly SensitiveSourceValueProtector _protector; - public CreateConfigEntryHandler(IConfigEntryStore store, AuditRecorder audit, SensitiveSourceValueProtector protector) + public CreateConfigEntryHandler(IConfigEntryStore store, IScopeStore scopeStore, AuditRecorder audit, SensitiveSourceValueProtector protector) { _store = store ?? throw new ArgumentNullException(nameof(store)); + _scopeStore = scopeStore ?? throw new ArgumentNullException(nameof(scopeStore)); _audit = audit ?? throw new ArgumentNullException(nameof(audit)); _protector = protector ?? throw new ArgumentNullException(nameof(protector)); } @@ -41,7 +43,8 @@ public static void Endpoint(IEndpointRouteBuilder endpoints) private async Task HandleAsync(CreateConfigEntryRequest request, CancellationToken cancellationToken = default) { var timestamp = DateTimeOffset.UtcNow; - var plaintextValues = request.Values.Select(v => new ScopedValue(v.Value, v.Scopes)); + var normalizedValues = await ConfigEntryValidation.NormalizeScopeKeysAsync(request.Values, _scopeStore, cancellationToken).ConfigureAwait(false); + var plaintextValues = normalizedValues.Select(v => new ScopedValue(v.Value, v.Scopes)); var protectedValues = _protector.ProtectValues(plaintextValues, request.IsSensitive); var entry = new ConfigEntry diff --git a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs index be5639fd..1660d566 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs @@ -12,12 +12,14 @@ namespace GroundControl.Api.Features.ConfigEntries; internal sealed class UpdateConfigEntryHandler : IEndpointHandler { private readonly IConfigEntryStore _store; + private readonly IScopeStore _scopeStore; private readonly AuditRecorder _audit; private readonly SensitiveSourceValueProtector _protector; - public UpdateConfigEntryHandler(IConfigEntryStore store, AuditRecorder audit, SensitiveSourceValueProtector protector) + public UpdateConfigEntryHandler(IConfigEntryStore store, IScopeStore scopeStore, AuditRecorder audit, SensitiveSourceValueProtector protector) { _store = store ?? throw new ArgumentNullException(nameof(store)); + _scopeStore = scopeStore ?? throw new ArgumentNullException(nameof(scopeStore)); _audit = audit ?? throw new ArgumentNullException(nameof(audit)); _protector = protector ?? throw new ArgumentNullException(nameof(protector)); } @@ -69,7 +71,8 @@ private async Task HandleAsync(Guid id, UpdateConfigEntryRequest reques // raw stored bytes always differ even when the underlying plaintext is identical). var oldPlaintextValues = _protector.UnprotectValues(entry.Values, oldIsSensitive); - var newPlaintextValues = request.Values.Select(v => new ScopedValue(v.Value, v.Scopes)).ToList(); + var normalizedValues = await ConfigEntryValidation.NormalizeScopeKeysAsync(request.Values, _scopeStore, cancellationToken).ConfigureAwait(false); + var newPlaintextValues = normalizedValues.Select(v => new ScopedValue(v.Value, v.Scopes)).ToList(); var protectedValues = _protector.ProtectValues(newPlaintextValues, request.IsSensitive); entry.Key = request.Key; diff --git a/tests/GroundControl.Api.Tests/ConfigEntries/ConfigEntriesHandlerTests.cs b/tests/GroundControl.Api.Tests/ConfigEntries/ConfigEntriesHandlerTests.cs index d795ff1d..9aab88f8 100644 --- a/tests/GroundControl.Api.Tests/ConfigEntries/ConfigEntriesHandlerTests.cs +++ b/tests/GroundControl.Api.Tests/ConfigEntries/ConfigEntriesHandlerTests.cs @@ -444,6 +444,74 @@ public async Task PutConfigEntry_WithStaleIfMatch_ReturnsConflict() problem.Detail.ShouldContain("Version conflict"); } + [Fact] + public async Task PostConfigEntry_WithScopeKeyCasingDifferentFromCanonical_NormalizesToCanonicalCasing() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var template = await CreateTemplateAsync(apiClient, "Test Template", TestCancellationToken); + await CreateScopeAsync(apiClient, "Environment", ["dev", "prod"], TestCancellationToken); + + var request = new CreateConfigEntryRequest + { + Key = "ConnectionString", + OwnerId = template.Id, + OwnerType = ConfigEntryOwnerType.Template, + ValueType = "String", + // Client submits the dimension key in lowercase; the canonical scope is PascalCase. + // The handler is expected to normalize the stored key to the canonical "Environment". + Values = [new ScopedValueRequest { Scopes = new Dictionary { ["environment"] = "prod" }, Value = "Server=prod-db" }], + }; + + // Act + var response = await apiClient.PostAsJsonAsync("/api/config-entries", request, WebJsonSerializerOptions, TestCancellationToken); + var entry = await ReadConfigEntryAsync(response, TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.Created); + var stored = entry.Values.ShouldHaveSingleItem(); + stored.Scopes.ShouldNotBeNull(); + stored.Scopes!.Keys.ShouldContain("Environment"); + stored.Scopes.Keys.ShouldNotContain("environment"); + } + + [Fact] + public async Task PutConfigEntry_WithScopeKeyCasingDifferentFromCanonical_NormalizesToCanonicalCasing() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var template = await CreateTemplateAsync(apiClient, "Test Template", TestCancellationToken); + await CreateScopeAsync(apiClient, "Environment", ["dev", "prod"], TestCancellationToken); + var created = await CreateConfigEntryAsync(apiClient, "Renormalize", template.Id, TestCancellationToken); + var getResponse = await apiClient.GetAsync($"/api/config-entries/{created.Id}", TestCancellationToken); + var etag = getResponse.Headers.ETag?.ToString(); + + using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/config-entries/{created.Id}"); + request.Content = JsonContent.Create( + new UpdateConfigEntryRequest + { + Key = created.Key, + ValueType = "String", + Values = [new ScopedValueRequest { Scopes = new Dictionary { ["ENVIRONMENT"] = "dev" }, Value = "Server=dev-db" }], + }, + options: WebJsonSerializerOptions); + + request.Headers.TryAddWithoutValidation("If-Match", etag); + + // Act + var response = await apiClient.SendAsync(request, TestCancellationToken); + var entry = await ReadConfigEntryAsync(response, TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var scoped = entry.Values.ShouldHaveSingleItem(); + scoped.Scopes.ShouldNotBeNull(); + scoped.Scopes!.Keys.ShouldContain("Environment"); + scoped.Scopes.Keys.ShouldNotContain("ENVIRONMENT"); + } + [Fact] public async Task PutConfigEntry_WithRenamedKey_ReturnsUpdatedEntryWithNewKey() { From ed5abcad97109443bf143cc40a714a7b5c87a955 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 13:35:55 +0100 Subject: [PATCH 09/25] fix(tower): render canonical scope dimension when stored key differs only by case Match scope dimensions case-insensitively so legacy entries written with lowercase keys still resolve to their canonical scope, and render the SelectItem with the form's stored value when it differs only by case so Radix's strict value comparison still picks the right option. Gate the "(deleted)" / "(no longer allowed)" labels behind isSuccess so the brief loading window does not flash stored values as deleted. --- .../tower/config/EntryModal.test.tsx | 128 +++++++++++++++++- .../components/tower/config/EntryModal.tsx | 15 +- .../tower/data/ScopedValuesField.tsx | 43 +++++- 3 files changed, 173 insertions(+), 13 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx index 0a933bbc..a6e3b063 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx @@ -6,8 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EntryModal } from './EntryModal'; import type { ConfigEntry } from '@/queries/useConfigEntries'; +const scopesMock = vi.fn(); vi.mock('@/queries/useScopes', () => ({ - useScopes: () => ({ data: { data: [] } }), + useScopes: () => scopesMock(), })); const updateEntryMock = vi.fn(); @@ -52,7 +53,7 @@ function buildEntry(overrides: Partial = {}): ConfigEntry { } as ConfigEntry; } -function buildSensitiveEntry(): ConfigEntry { +function buildSensitiveEntry(overrides: Partial = {}): ConfigEntry { return { createdAt: '2026-01-01T00:00:00Z', createdBy: '00000000-0000-0000-0000-000000000000', @@ -67,6 +68,7 @@ function buildSensitiveEntry(): ConfigEntry { valueType: 'String', values: [{ scopes: {}, value: SENSITIVE_MASK }], version: '1', + ...overrides, } as ConfigEntry; } @@ -75,6 +77,128 @@ describe('EntryModal', () => { updateEntryMock.mockReset(); updateEntryMock.mockResolvedValue(undefined); getConfigEntryMock.mockReset(); + scopesMock.mockReset(); + scopesMock.mockReturnValue({ data: { data: [] }, isSuccess: true }); + }); + + it('renders the canonical dimension name when the stored key only differs by case', async () => { + // Backend stores the scope as "Environment" but the entry was written with a lowercase + // dimension key ("environment"). The Select trigger should still display "Environment" since + // the dimensions are case-insensitive on the server. + scopesMock.mockReturnValue({ + data: { + data: [ + { id: 'dim-env', dimension: 'Environment', allowedValues: ['dev', 'prod'] }, + ], + }, + isSuccess: true, + }); + + const entry = buildSensitiveEntry({ + values: [ + { scopes: {}, value: SENSITIVE_MASK }, + { scopes: { environment: 'prod' }, value: SENSITIVE_MASK }, + ], + }); + + renderWithClient( + , + ); + + await waitFor(() => { + const triggers = screen.getAllByRole('combobox'); + const triggerTexts = triggers.map((trigger) => trigger.textContent?.trim()); + expect(triggerTexts).toContain('Environment'); + expect(triggerTexts.some((text) => text?.includes('(deleted)'))).toBe(false); + expect(triggerTexts).toContain('prod'); + }); + }); + + it('does not label the dimension as deleted while the scopes query is still loading', async () => { + // While useScopes() is in-flight (isSuccess === false), the dimensions list is empty but we + // cannot conclude the scope is gone — the stored value should render plainly until the query + // resolves. + scopesMock.mockReturnValue({ data: undefined, isSuccess: false }); + + const entry = buildSensitiveEntry({ + values: [ + { scopes: {}, value: SENSITIVE_MASK }, + { scopes: { environment: 'prod' }, value: SENSITIVE_MASK }, + ], + }); + + renderWithClient( + , + ); + + await waitFor(() => { + const triggers = screen.getAllByRole('combobox'); + const triggerTexts = triggers.map((trigger) => trigger.textContent?.trim()); + expect(triggerTexts.some((text) => text === 'environment')).toBe(true); + expect(triggerTexts.some((text) => text?.includes('(deleted)'))).toBe(false); + }); + }); + + it('keeps the scoped row visible when the referenced scope is no longer in the scopes list', async () => { + // The entry was created when "environment" was a defined scope. The scope has since been + // deleted or renamed in /api/scopes, so the dimensions list returned by useScopes() doesn't + // include it. The stored dimension/value must still render so the user can see and clean it up. + scopesMock.mockReturnValue({ + data: { + data: [ + { id: 'dim-region', dimension: 'region', allowedValues: ['us', 'eu'] }, + ], + }, + isSuccess: true, + }); + + const entry = buildSensitiveEntry({ + values: [ + { scopes: {}, value: SENSITIVE_MASK }, + { scopes: { environment: 'prod' }, value: SENSITIVE_MASK }, + ], + }); + + renderWithClient( + , + ); + + await waitFor(() => { + const triggers = screen.getAllByRole('combobox'); + const triggerTexts = triggers.map((trigger) => trigger.textContent?.trim()); + expect(triggerTexts.some((text) => text?.includes('environment'))).toBe(true); + expect(triggerTexts.some((text) => text?.includes('prod'))).toBe(true); + }); + }); + + it('renders dimension and scope value for a sensitive entry that has scoped values', async () => { + scopesMock.mockReturnValue({ + data: { + data: [ + { id: 'dim-env', dimension: 'environment', allowedValues: ['dev', 'prod'] }, + ], + }, + isSuccess: true, + }); + + const entry = buildSensitiveEntry({ + values: [ + { scopes: {}, value: SENSITIVE_MASK }, + { scopes: { environment: 'prod' }, value: SENSITIVE_MASK }, + ], + }); + + renderWithClient( + , + ); + + // Drain the open-useEffect that calls form.reset(formValues) — the bug surfaces after that fires. + await waitFor(() => { + const triggers = screen.getAllByRole('combobox'); + const triggerTexts = triggers.map((trigger) => trigger.textContent?.trim()); + expect(triggerTexts).toContain('environment'); + expect(triggerTexts).toContain('prod'); + }); }); it('rejects invalid key characters', async () => { diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx index b6fe1df8..dea96a84 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx @@ -224,12 +224,15 @@ export function EntryModal({ entry, initialKey, mode, onOpenChange, open, ownerI } function toFormValues(entry?: ConfigEntry, initialKey?: string): EntryFormValues { - const defaultScopedValue = entry?.values.find((value) => !value.scopes || Object.keys(value.scopes).length === 0); - const scopedValues = entry?.values.filter((value) => value !== defaultScopedValue).map((value) => { - const [dimension = '', scopeValue = ''] = Object.entries(value.scopes ?? {})[0] ?? []; - - return { dimension, scopeValue, value: value.value }; - }) ?? []; + const values = entry?.values ?? []; + const defaultScopedValue = values.find((value) => !value.scopes || Object.keys(value.scopes).length === 0); + const scopedValues = values + .filter((value) => value.scopes && Object.keys(value.scopes).length > 0) + .map((value) => { + const [dimension = '', scopeValue = ''] = Object.entries(value.scopes ?? {})[0] ?? []; + + return { dimension, scopeValue, value: value.value }; + }); return { defaultValue: defaultScopedValue?.value ?? '', diff --git a/src/GroundControl.Tower/src/components/tower/data/ScopedValuesField.tsx b/src/GroundControl.Tower/src/components/tower/data/ScopedValuesField.tsx index 6726414c..9b05e12c 100644 --- a/src/GroundControl.Tower/src/components/tower/data/ScopedValuesField.tsx +++ b/src/GroundControl.Tower/src/components/tower/data/ScopedValuesField.tsx @@ -33,6 +33,7 @@ export function ScopedValuesField({ }: ScopedValuesFieldProps) { const scopes = useScopes(); const dimensions = scopes.data?.data ?? []; + const scopesLoaded = scopes.isSuccess; const scopedValues = useFieldArray({ control, name: 'scopedValues' as ArrayPath }); return ( @@ -57,7 +58,20 @@ export function ScopedValuesField({ const scopeValuePath = `scopedValues.${index}.scopeValue` as Path; const valuePath = `scopedValues.${index}.value` as Path; const dimension = watch(dimensionPath) as string | undefined; - const selectedScope = dimensions.find((scope) => scope.dimension === dimension); + const scopeValue = watch(scopeValuePath) as string | undefined; + // Stored entries can use a different case for the dimension key than the canonical scope + // (validator + index are case-insensitive by collation). Match case-insensitively so the + // canonical scope still resolves; the SelectItem render preserves the stored case so Radix + // can match the form's value verbatim. + const selectedScope = dimensions.find((scope) => scope.dimension.toLowerCase() === dimension?.toLowerCase()); + const dimensionMissing = Boolean(dimension) && !selectedScope; + const scopeValueMissing = Boolean(scopeValue) && !!selectedScope && !selectedScope.allowedValues.includes(scopeValue!); + const fallbackDimension = dimensionMissing ? dimension! : null; + const fallbackScopeValue = !selectedScope && Boolean(scopeValue) ? scopeValue! : scopeValueMissing ? scopeValue! : null; + // Only label as "(deleted) / (no longer allowed)" once /api/scopes has actually resolved. + // Otherwise the brief loading window flashes the stored value as deleted. + const dimensionConfirmedDeleted = scopesLoaded && dimensionMissing; + const scopeValueConfirmedRemoved = scopesLoaded && scopeValueMissing; return (
@@ -74,9 +88,23 @@ export function ScopedValuesField({ - {dimensions.map((scope) => ( - {scope.dimension} - ))} + {dimensions.map((scope) => { + // If the stored dimension matches this canonical scope only by case, render + // the SelectItem with the stored value so Radix's strict comparison succeeds. + // The displayed text stays canonical. Backend write-side normalizes future + // saves so this branch is a transitional render for legacy entries. + const usesStoredCase = !!dimension + && dimension !== scope.dimension + && dimension.toLowerCase() === scope.dimension.toLowerCase(); + const itemValue = usesStoredCase ? dimension! : scope.dimension; + + return {scope.dimension}; + })} + {fallbackDimension ? ( + + {fallbackDimension}{dimensionConfirmedDeleted ? ' (deleted)' : ''} + + ) : null} )} @@ -86,7 +114,7 @@ export function ScopedValuesField({ name={scopeValuePath} render={({ field: valueField }) => ( )} From 04c8cfa2264c9ab027fd7f61ec5f16a85de1f6b3 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 14:30:40 +0100 Subject: [PATCH 10/25] feat(tower): replace active/previous diff buttons with searchable compare picker Collapse the snapshot view segmented control to JSON + a Compare dropdown that lists every published snapshot with a search input, so users can diff the selected snapshot against any version instead of just the active or previous one. The picker defaults to the active snapshot (or the next one in the list when the active one is being viewed) and persists user preference through a v2 tweaks-store migration that rewrites the legacy 'diff'/'json-diff' modes to 'compare'. --- .../tower/snapshots/CompareSnapshotPicker.tsx | 140 ++++++++++++++++++ .../routes/projects/$projectId/snapshots.tsx | 103 ++++++------- src/GroundControl.Tower/src/store/tweaks.ts | 14 +- 3 files changed, 198 insertions(+), 59 deletions(-) create mode 100644 src/GroundControl.Tower/src/components/tower/snapshots/CompareSnapshotPicker.tsx diff --git a/src/GroundControl.Tower/src/components/tower/snapshots/CompareSnapshotPicker.tsx b/src/GroundControl.Tower/src/components/tower/snapshots/CompareSnapshotPicker.tsx new file mode 100644 index 00000000..6ab97847 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/snapshots/CompareSnapshotPicker.tsx @@ -0,0 +1,140 @@ +import { Check, ChevronDown, GitCompareArrows, Search } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Badge } from '@/components/tower/data/Badge'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import type { SnapshotSummary } from '@/queries/useSnapshots'; + +interface CompareSnapshotPickerProps { + activeSnapshotId?: string; + active: boolean; + compareSnapshotId?: string; + disabled?: boolean; + onSelect: (snapshotId: string) => void; + selectedSnapshotId?: string; + size?: 'md' | 'sm'; + snapshots: SnapshotSummary[]; +} + +const sizeClassNames = { + md: 'ui-text-body-sm h-7 px-3', + sm: 'ui-text-caption h-6 px-2.5', +} as const; + +export function CompareSnapshotPicker({ + active, + activeSnapshotId, + compareSnapshotId, + disabled = false, + onSelect, + selectedSnapshotId, + size = 'md', + snapshots, +}: CompareSnapshotPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const searchRef = useRef(null); + const compareSummary = useMemo( + () => snapshots.find((snapshot) => snapshot.id === compareSnapshotId), + [compareSnapshotId, snapshots], + ); + + useEffect(() => { + if (open) { + setSearch(''); + // Defer focus until the popover content has mounted. + requestAnimationFrame(() => searchRef.current?.focus()); + } + }, [open]); + + const filtered = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) { + return snapshots; + } + + return snapshots.filter((snapshot) => { + const version = `v${snapshot.snapshotVersion}`.toLowerCase(); + const description = snapshot.description?.toLowerCase() ?? ''; + return version.includes(term) || description.includes(term); + }); + }, [search, snapshots]); + + const label = active && compareSummary + ? `Compare with v${compareSummary.snapshotVersion}${compareSummary.id === activeSnapshotId ? ' (active)' : ''}` + : 'Compare'; + + return ( + + + + +
+
+
+ {filtered.length === 0 ? ( +
No snapshots match your search.
+ ) : ( + filtered.map((snapshot) => { + const isCompareTarget = snapshot.id === compareSnapshotId && active; + const isActive = snapshot.id === activeSnapshotId; + const isSelected = snapshot.id === selectedSnapshotId; + + return ( + + ); + }) + )} +
+
+
+ ); +} diff --git a/src/GroundControl.Tower/src/routes/projects/$projectId/snapshots.tsx b/src/GroundControl.Tower/src/routes/projects/$projectId/snapshots.tsx index 5dd38c77..3ab597e6 100644 --- a/src/GroundControl.Tower/src/routes/projects/$projectId/snapshots.tsx +++ b/src/GroundControl.Tower/src/routes/projects/$projectId/snapshots.tsx @@ -1,12 +1,11 @@ import { createFileRoute } from '@tanstack/react-router'; -import { GitCompareArrows } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/tower/data/Badge'; -import { SegmentedControl } from '@/components/tower/data/SegmentedControl'; +import { CompareSnapshotPicker } from '@/components/tower/snapshots/CompareSnapshotPicker'; import { summarizeChanges } from '@/components/tower/snapshots/PublishModal'; import { SnapshotDiffView } from '@/components/tower/snapshots/SnapshotDiffView'; import { SnapshotJsonView } from '@/components/tower/snapshots/SnapshotJsonView'; @@ -17,12 +16,6 @@ import { useProjects } from '@/queries/useProjects'; import { useActivateSnapshot, useSnapshotDetail, useSnapshots, type SnapshotDetail, type SnapshotSummary } from '@/queries/useSnapshots'; import { useTweaksStore } from '@/store/tweaks'; -const snapshotViewOptions = [ - { label: 'JSON', value: 'json' }, - { icon: GitCompareArrows, label: 'Active', value: 'diff' }, - { icon: GitCompareArrows, label: 'Previous', value: 'json-diff' }, -] as const; - export const Route = createFileRoute('/projects/$projectId/snapshots')({ component: SnapshotsRoute, }); @@ -34,24 +27,31 @@ function SnapshotsRoute() { const snapshotViewMode = useTweaksStore((state) => state.snapshotViewMode); const setSnapshotViewMode = useTweaksStore((state) => state.setSnapshotViewMode); const [selectedSnapshotId, setSelectedSnapshotId] = useState(); + const [compareSnapshotId, setCompareSnapshotId] = useState(); const [detailExpanded, setDetailExpanded] = useState(false); const [activatingSnapshot, setActivatingSnapshot] = useState(); const items = snapshots.data?.data ?? []; const project = projects.data?.data.find((candidate) => candidate.id === projectId); const projectName = project?.name ?? '—'; const activeSnapshotId = project?.activeSnapshotId || undefined; - const activeSummary = items.find((snapshot) => snapshot.id === activeSnapshotId); const selectedSnapshot = items.find((snapshot) => snapshot.id === selectedSnapshotId) ?? items[0]; const selectedIsActive = Boolean(selectedSnapshot && activeSnapshotId && selectedSnapshot.id === activeSnapshotId); const selectedIndex = items.findIndex((snapshot) => snapshot.id === selectedSnapshot?.id); const previousSummary = selectedIndex >= 0 ? items[selectedIndex + 1] : undefined; + // Default the compare target to the active snapshot when it differs from the one being viewed, + // otherwise fall back to the previous snapshot. The picker can override this at any time. + const defaultCompareId = activeSnapshotId && activeSnapshotId !== selectedSnapshot?.id ? activeSnapshotId : previousSummary?.id; + const effectiveCompareId = compareSnapshotId && items.some((snapshot) => snapshot.id === compareSnapshotId) + ? compareSnapshotId + : defaultCompareId; + const compareSummary = items.find((snapshot) => snapshot.id === effectiveCompareId); const selectedDetail = useSnapshotDetail(projectId, selectedSnapshot?.id); - const activeDetail = useSnapshotDetail(projectId, activeSnapshotId); - const previousDetail = useSnapshotDetail(projectId, previousSummary?.id); + const compareDetail = useSnapshotDetail(projectId, effectiveCompareId); const activateSnapshot = useActivateSnapshot(projectId); useEffect(() => { setSelectedSnapshotId(undefined); + setCompareSnapshotId(undefined); }, [projectId]); useEffect(() => { @@ -69,35 +69,18 @@ function SnapshotsRoute() { return description ? `v${selectedSnapshot.snapshotVersion} — ${description}` : `v${selectedSnapshot.snapshotVersion}`; }, [selectedSnapshot]); - const activeChangeSummary = useMemo(() => { - if (snapshotViewMode !== 'diff') { - return null; - } - - return summarizeChanges(snapshotToDocument(activeDetail.data), snapshotToDocument(selectedDetail.data)); - }, [activeDetail.data, selectedDetail.data, snapshotViewMode]); - - const previousChangeSummary = useMemo(() => { - if (snapshotViewMode !== 'json-diff') { + const compareChangeSummary = useMemo(() => { + if (snapshotViewMode !== 'compare') { return null; } - return summarizeChanges(snapshotToDocument(previousDetail.data), snapshotToDocument(selectedDetail.data)); - }, [previousDetail.data, selectedDetail.data, snapshotViewMode]); - - const comparisonLabel = useMemo(() => { - if (snapshotViewMode === 'diff') { - return activeSummary ? `vs v${activeSummary.snapshotVersion} (active)` : null; - } - - if (snapshotViewMode === 'json-diff') { - return previousSummary ? `vs v${previousSummary.snapshotVersion} (previous)` : 'no previous snapshot'; - } - - return null; - }, [activeSummary, previousSummary, snapshotViewMode]); + return summarizeChanges(snapshotToDocument(compareDetail.data), snapshotToDocument(selectedDetail.data)); + }, [compareDetail.data, selectedDetail.data, snapshotViewMode]); const selectedSnapshotLabel = selectedSnapshot ? `v${selectedSnapshot.snapshotVersion}` : 'selected snapshot'; + const compareTargetLabel = compareSummary + ? `v${compareSummary.snapshotVersion}${compareSummary.id === activeSnapshotId ? ' (active)' : ''}` + : 'no snapshot selected'; return (
@@ -150,7 +133,31 @@ function SnapshotsRoute() {
- +
+ + { + setCompareSnapshotId(id); + setSnapshotViewMode('compare'); + }} + selectedSnapshotId={selectedSnapshot?.id} + snapshots={items} + /> +
{renderSnapshotView()}
@@ -203,33 +210,17 @@ function SnapshotsRoute() { } function renderExpandedDiffView(expanded = true) { - if (snapshotViewMode === 'diff') { - return ( - setDetailExpanded(false) : undefined} - onExpand={expanded ? undefined : () => setDetailExpanded(true)} - snapshot={selectedDetail.data} - sourceLabel={selectedSnapshotLabel} - targetLabel={activeSummary ? `v${activeSummary.snapshotVersion} (active)` : 'active snapshot'} - /> - ); - } - return ( setDetailExpanded(false) : undefined} onExpand={expanded ? undefined : () => setDetailExpanded(true)} snapshot={selectedDetail.data} sourceLabel={selectedSnapshotLabel} - targetLabel={previousSummary ? `v${previousSummary.snapshotVersion} (previous)` : 'previous snapshot'} + targetLabel={compareTargetLabel} /> ); } diff --git a/src/GroundControl.Tower/src/store/tweaks.ts b/src/GroundControl.Tower/src/store/tweaks.ts index 3a934159..85db1600 100644 --- a/src/GroundControl.Tower/src/store/tweaks.ts +++ b/src/GroundControl.Tower/src/store/tweaks.ts @@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware'; export type Theme = 'light' | 'dark'; export type ConfigViewMode = 'list' | 'tree' | 'json'; -export type SnapshotViewMode = 'diff' | 'json' | 'json-diff'; +export type SnapshotViewMode = 'compare' | 'json'; export type DiffLayout = 'inline' | 'split'; interface TweaksState { @@ -43,18 +43,26 @@ export const useTweaksStore = create()( set({ theme }); applyToDocument(theme); }, - snapshotViewMode: 'diff', + snapshotViewMode: 'compare', theme: 'light', }), { name: 'tower.tweaks', // v1: configViewMode 'flat' renamed to 'list'. - version: 1, + // v2: snapshotViewMode 'diff'|'json-diff' collapsed into 'compare'. + version: 2, migrate: (persistedState, version) => { if (version < 1 && persistedState && typeof persistedState === 'object' && (persistedState as { configViewMode?: string }).configViewMode === 'flat') { (persistedState as { configViewMode: ConfigViewMode }).configViewMode = 'list'; } + if (version < 2 && persistedState && typeof persistedState === 'object') { + const legacy = (persistedState as { snapshotViewMode?: string }).snapshotViewMode; + if (legacy === 'diff' || legacy === 'json-diff') { + (persistedState as { snapshotViewMode: SnapshotViewMode }).snapshotViewMode = 'compare'; + } + } + return persistedState as TweaksState; }, onRehydrateStorage: () => (state) => { From 82885f11440db126fa74bfcb16cd9ac43512a5a5 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 14:55:17 +0100 Subject: [PATCH 11/25] fix(tower): stop loading skeleton from overlapping with progressively-loaded clients The global /clients page aggregates per-project queries via useQueries, so data fills in as each project resolves while isLoading stays true until the last one finishes. The skeleton and the partially-loaded list were rendering at the same time. Suppress the skeleton once any data has arrived. Also keep the per-project clients table container stable across loading so the body swaps between skeleton rows and real rows in place instead of replacing a fixed-height block. --- .../src/routes/clients.tsx | 7 ++- .../routes/projects/$projectId/clients.tsx | 48 +++++++++++-------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/GroundControl.Tower/src/routes/clients.tsx b/src/GroundControl.Tower/src/routes/clients.tsx index 40c0a9d8..6e2bf27c 100644 --- a/src/GroundControl.Tower/src/routes/clients.tsx +++ b/src/GroundControl.Tower/src/routes/clients.tsx @@ -60,9 +60,12 @@ function ClientsRoute() {
- {allClients.isLoading ? : null} + {/* useAllClients aggregates per-project queries; its data fills in progressively while + isLoading stays true until the last project resolves. Suppress the skeleton as soon + as any data arrives so it doesn't render alongside partially-loaded results. */} + {allClients.isLoading && allClients.data.length === 0 ? : null} {!allClients.isLoading && allClients.data.length === 0 ?
No clients yet.
: null} - {!allClients.isLoading && allClients.data.length > 0 && filtered.length === 0 ?
No clients match the current filter.
: null} + {allClients.data.length > 0 && filtered.length === 0 ?
No clients match the current filter.
: null} {filtered.length > 0 ? (
    diff --git a/src/GroundControl.Tower/src/routes/projects/$projectId/clients.tsx b/src/GroundControl.Tower/src/routes/projects/$projectId/clients.tsx index 37c0ad6c..1fc45050 100644 --- a/src/GroundControl.Tower/src/routes/projects/$projectId/clients.tsx +++ b/src/GroundControl.Tower/src/routes/projects/$projectId/clients.tsx @@ -32,29 +32,39 @@ function ClientsRoute() { return (
    -
    +
    - {clients.isLoading ? : ( -
    -
    -
- - {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())})} - ))} - - - {table.getRowModel().rows.map((row) => ( - {row.getVisibleCells().map((cell) => {flexRender(cell.column.columnDef.cell, cell.getContext())})} - ))} - {table.getRowModel().rows.length === 0 ? No client credentials found. : null} - -
-
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + {headerGroup.headers.map((header) => {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())})} + ))} + + + {clients.isLoading ? ( + Array.from({ length: 4 }).map((_, rowIndex) => ( + + {columns.map((_column, cellIndex) => ( + + ))} + + )) + ) : ( + <> + {table.getRowModel().rows.map((row) => ( + {row.getVisibleCells().map((cell) => {flexRender(cell.column.columnDef.cell, cell.getContext())})} + ))} + {table.getRowModel().rows.length === 0 ? No client credentials found. : null} + + )} + +
- )} +
{ if (!open) { setClientToRevoke(null); } }} open={clientToRevoke !== null} projectId={projectId} /> ); From 74aabbfb8757c8ac9164a5ddd7827530f06222a7 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Fri, 15 May 2026 15:28:27 +0100 Subject: [PATCH 12/25] refactor(tower): drop redundant form placeholders and pad dialog footer Remove placeholder hints that just restate the field label, the variable-editor tier description preamble, and add top padding to the dialog footer so action buttons aren't flush against form fields. --- .../src/components/tower/config/EntryModal.tsx | 1 - .../src/components/tower/config/EntryValue.tsx | 2 +- .../src/components/tower/data/ScopedValuesField.tsx | 1 - .../components/tower/variables/VariableEditorModal.tsx | 9 +-------- src/GroundControl.Tower/src/components/ui/dialog.tsx | 2 +- src/GroundControl.Tower/src/routes/scopes.tsx | 2 +- .../src/routes/templates/$templateId.tsx | 2 +- src/GroundControl.Tower/src/routes/templates/index.tsx | 2 +- 8 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx index dea96a84..d12d1bf2 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx @@ -185,7 +185,6 @@ export function EntryModal({ entry, initialKey, mode, onOpenChange, open, ownerI