Skip to content

feat(create-cluster): render ClusterCreationPolicy curation in modal - #76

Merged
atbagan merged 12 commits into
mainfrom
feat/clustercreationpolicy-ui
May 19, 2026
Merged

atbagan merged 12 commits into
mainfrom
feat/clustercreationpolicy-ui

Conversation

@atbagan

@atbagan atbagan commented May 18, 2026 •

Copy link
Copy Markdown
Contributor

Fifth PR in the ADR-018 implementation chain.

What this adds

Modal-time policy rendering (initial scope)

  • New PolicyMode union and PolicyMetadata interface in src/api/providers.ts, optional policy field on the four list response shapes
  • Policy state hooks in CreateClusterPage that capture the optional policy block from each list response and apply default pre-selection
  • PolicyBadge rendered 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)

  • New src/api/policies.ts API client with policiesApi (list / get / create / update / delete) and types matching the ADR-018 schema, plus a WebhookError type that matches butler-server's writeWebhookError shape
  • New shared src/components/policy/PolicyForm.tsx with scope picker, provider multi-select, and per-rule editor
  • Three new pages under /admin/policies: list, create, view/edit/delete
  • App.tsx wires the routes under RequireAdmin; Sidebar gains a "Creation Policies" entry in the Platform section
  • Webhook denials surface inline against the offending field path

Depends on

Implementation chain status

Verification

  • npx tsc --noEmit clean
  • npm run build clean
  • Lint zero errors on changed files

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.
Andrew Bagan 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.
@atbagan
atbagan merged commit ae197a8 into main May 19, 2026
8 checks passed
@atbagan
atbagan deleted the feat/clustercreationpolicy-ui branch May 19, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant