Repository navigation
feat(create-cluster): render ClusterCreationPolicy curation in modal - #76
Merged
Merged
Conversation
Consumes the optional policy block butler-server now appends to the
four provider option-list responses. The console does not re-resolve
policy; the server already filtered for pin and allowList and
reordered for recommended, so the modal renders the server-applied
state and adds the operator-facing affordances.
API typings:
- New PolicyMode union and PolicyMetadata interface in
src/api/providers.ts
- Optional policy field on ImageListResponse, NetworkListResponse,
ClusterListResponse, StorageContainerListResponse
- Both types re-exported from src/api/index.ts
CreateClusterPage:
- Four new policy state hooks (imagePolicy, networkPolicy,
clusterPolicy, storagePolicy) capture the policy block from each
list response
- Auto-select honors policy.default when the default ID is in the
filtered list, falling back to the existing heuristic otherwise
- New PolicyBadge component renders below affected dropdowns,
distinguishing pin ("Pinned by policy"), allowList ("Filtered by
policy"), and recommended ("Recommended by policy" with the
recommendedReason text). default mode stays silent since the
pre-selection conveys intent without extra UI.
- HarvesterFields and NutanixFields accept the new policy props and
render the badge under each dropdown they own (Harvester network
and image; Nutanix cluster, subnet, image, and storage container).
Depends on butlerdotdev/butler-server#74 for the response envelope
change. Existing API consumers without the new field continue to
work because the policy block is optional.
8 tasks done
added 11 commits
May 18, 2026 13:52
Extends PR #76 scope per ADR-018 amendment promoting UI authoring to v1 (butler-controller#106). Companion to butler-server admin endpoints landed in PR #74 commit 326c539. API client (src/api/policies.ts, re-exported from src/api/index.ts): - ClusterCreationPolicy, PolicyOptionType, PolicyOptionMode, PolicyOptionRule, PolicyScope, PolicyListResponse types - WebhookError type matching butler-server's writeWebhookError shape - policiesApi with list, get, create, update, delete Shared form (src/components/policy/PolicyForm.tsx): - Scope picker with three radios (clusterWide, team, teamAndEnv) and dependent team / environment dropdowns - Provider multi-select from the ProviderType enum - Per-rule editor with Option Type, Mode, Values (textarea), Default (Input), Recommended Reason (Input) - Local-validation messaging plus webhook denial inline rendering keyed on WebhookError.field Three pages under /admin/policies: - PoliciesListPage — list view with name, scope, providers, option types, age columns; empty state with CTA; New Policy button - PolicyCreatePage — wraps PolicyForm for create flow - PolicyDetailPage — view mode with three read-only cards (scope, providers, option rules); Edit toggles to PolicyForm; Delete with confirmation modal App.tsx wires three routes under RequireAdmin. Sidebar gains a "Creation Policies" link under the Platform section. Webhook denials surface inline against the offending field path (spec.options[<option-type>] for rule conflicts; other field paths as butler-server forwards them).
PolicyForm now fetches the cluster's configured ProviderConfigs at mount and drives both the targetProviders selection and the value pickers from live data instead of hardcoded type lists and free-text ID entry. targetProviders: - Filtered to provider types that have at least one ProviderConfig on the cluster (queried via providersApi.list). Avoids offering aws/azure/gcp when no such ProviderConfig exists. - Empty-state messaging when no ProviderConfigs are configured. Discovery provider: - New section in the form. Picks a specific ProviderConfig that the value pickers use as their data source. Defaults to the first configured provider matching the selected targetProviders. - Choice does not change what the policy targets; it sources the dropdowns. Targeting is the targetProviders list. Value pickers per option rule: - pin, allowList, recommended: chip list of selected entries plus a SearchableSelect of remaining entries. Entries labelled by name with the ID truncated as a suffix. - default: SearchableSelect with the discovery provider's entries. - "Edit raw IDs" per-rule toggle falls back to the original textarea or text input. Used for GitOps imports that reference IDs the picker can't resolve, or for entries the provider has removed. Per-option-type provider support matrix lives in PROVIDER_OPTION_SUPPORT in PolicyForm. Nutanix supports all four; Harvester supports image and network. Cluster and storageContainer on Harvester fall back to raw IDs with an inline notice. Pages drop the now-internal PROVIDER_TYPES constant; the form owns the configured-types lookup.
PolicyForm defined ValuePicker and DefaultPicker as inner functions inside the PolicyForm component body. Inner-component definitions create a new function reference on every parent render, so React treats them as new component types and remounts on every PolicyForm re-render. The SearchableSelect's internal open state resets to false on remount, closing the dropdown the instant a state change rerendered the form (such as the click that opened it). Fix: inline the picker JSX directly inside the rule loop. No more inner components. Entry fetching moves to a single useEffect on PolicyForm keyed on discoveryKey and rules.length, so the form still lazy-loads option lists when the discovery provider or rule set changes. Same behavior as the previous implementation for the user; the dropdown now stays open and supports filter and keyboard nav as the underlying SearchableSelect intended.
PolicyForm previously rendered pin with the same multi-value chip list as allowList and recommended. The name "pin" implies a single fixed value; the form should match. Pin now renders as a single-select identical in UX to default mode, storing the chosen ID as Values[0]. Local form validation rejects pin rules with zero or more-than-one values. The policy admission webhook in butler-controller enforces the same constraint at the schema level (companion fix in PR #105). allowList and recommended continue to accept multiple values as before. The orthogonal mode grid is now clean: pin: single value, enforcing default: single value, advisory allowList: multi value, enforcing recommended: multi value, advisory
Two small UX improvements on the new admin policies tab. Name: "Creation Policies" pigeonholes the surface to the single ClusterCreationPolicy CRD that exists today. The URL is already /admin/policies (forward-friendly); aligning the label keeps the sidebar future-proof when other policy CRDs land alongside. The tab already lives under the Platform section, so prefixing with "Platform" would be redundant. Icon: the previous AccessControlIcon (shield + check) is also used by the Access Control tab right below in the same section. New PoliciesIcon is a clipboard with a check, the common policy/audit metaphor, distinct from the shield motif. Page H1 stays "Cluster Creation Policies" for now since the page specifically lists that one CRD. Generalizes when other policy kinds arrive.
Previously /admin/policies jumped straight to the ClusterCreationPolicy list, which pigeonholes the surface. /admin/policies now renders a hub of cards, one per policy kind, with each card linking into a kind- specific list. ClusterCreationPolicies is the one card today; future policy CRDs (upgrade windows, addon governance, network pool allocation) slot in as additional cards without restructuring routes. Route restructure: /admin/policies -> PoliciesHubPage (new) /admin/policies/cluster-creation -> PoliciesListPage /admin/policies/cluster-creation/new -> PolicyCreatePage /admin/policies/cluster-creation/:name -> PolicyDetailPage Hub card shows: kind title, description, live policy count (fetched on mount), "Manage" CTA, and the ADR reference tag. Cards are clickable. Disabled future-kind cards can be added later as "coming soon" placeholders when work begins. Sidebar entry unchanged at /admin/policies (now lands on the hub). Server-side API endpoints unchanged. Internal navigation in PolicyCreatePage and PolicyDetailPage updated to redirect to /admin/policies/cluster-creation after create / delete / cancel. PoliciesListPage create button and detail link updated likewise.
Two UI polish items. Back buttons: the deeper policy pages now have a back affordance that returns one level up the hierarchy rather than relying on browser history. Cluster Creation list -> back to Policies hub New Cluster Creation -> back to Cluster Creation list Detail view -> back to Cluster Creation list Detail edit mode -> back to Detail view (cancels edit) Each back button is the standard chevron-left icon with the parent page name as the label, matching the AdminTeamDetailPage pattern. ADR tag: hub card no longer shows "ADR-018" in a chip. The ADR reference belongs in the design doc, not as user-facing UI metadata on the policy authoring surface.
The detail page previously rendered every option rule as "values: <uuid>, <uuid>" regardless of mode. Two improvements. Name resolution. The detail page now fetches the configured ProviderConfigs and the per-option-type entry lists from the first eligible discovery provider, then displays each ID alongside the provider-returned name. Fetch is best-effort and per-option-type cached in state; on failure the page falls back to bare IDs so the view still functions when the provider is unreachable. Mode-specific display: - pin: rendered as "Value: <name> (<id prefix>)" singular - allowList and recommended: bulleted list of names with id prefix per entry, count in the section heading - default: rendered as "Default: <name> (<id prefix>)" - recommended reason: free text on its own line ID prefix (first 8 chars) shows alongside each name for disambiguation when multiple entries share a label and for operator-facing kubectl correlation. Stored IDs are unchanged.
The Scope card on the detail page rendered the raw spec.scope
object as a JSON pre block. Readable to anyone living in YAML, but
unfriendly to an admin who just wants to see "this policy targets
team observability-engineering".
Replaced with a definition list keyed on the scope kind:
Cluster-wide:
Type: Cluster-wide
Applies to: Every team on this cluster
Team:
Type: Team
Team: <linked team name -> /admin/teams/:name>
Team and environment:
Type: Team and environment
Team: <linked team name>
Environment: <env name>
Team name links to the admin team detail page. Environment shown
as plain monospace text since there's no per-env admin view to
link to today. ID prefix not relevant here since teams and envs
are named identifiers, not UUIDs.
Companion to butler-api commit 76b3e2b and butler-controller commit ae79e68. The schema rename replaces "Cluster-wide" with "Platform-wide" since the policy applies to every team and every environment on the Butler platform, not to a specific Kubernetes cluster. - ScopeKind union in PolicyForm: 'clusterWide' -> 'platformWide' - PolicyScope interface in src/api/policies.ts: field rename - Form radio label "Cluster-wide" -> "Platform-wide" - Detail page rendering branches on the new field name with the updated user-facing label and "Applies to: Every team and environment on this Butler platform" - List page scope summary: "cluster-wide" -> "platform-wide" No behavior change. UI strings now match the schema and the hub copy.
Three-pass review caught an em-dash on PoliciesHubPage as the empty-state placeholder for the policy count. Butler discipline disallows em-dashes in shipped code. Replaced the single em-dash with an ASCII hyphen. PR #76 diff is now em-dash free; pre-existing em-dashes elsewhere in the codebase are not in this PR's scope.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fifth PR in the ADR-018 implementation chain.
What this adds
Modal-time policy rendering (initial scope)
PolicyModeunion andPolicyMetadatainterface insrc/api/providers.ts, optionalpolicyfield on the four list response shapesCreateClusterPagethat capture the optionalpolicyblock from each list response and apply default pre-selectionPolicyBadgerendered under each affected dropdown distinguishing pin / allowList / recommended (default mode stays silent)Admin authoring pages (added in commit c210944 per ADR-018 amendment butlerdotdev/butler-controller#106)
src/api/policies.tsAPI client withpoliciesApi(list / get / create / update / delete) and types matching the ADR-018 schema, plus aWebhookErrortype that matches butler-server'swriteWebhookErrorshapesrc/components/policy/PolicyForm.tsxwith scope picker, provider multi-select, and per-rule editor/admin/policies: list, create, view/edit/deleteRequireAdmin; Sidebar gains a "Creation Policies" entry in the Platform sectionDepends on
Implementation chain status
Verification
npx tsc --noEmitcleannpm run buildclean