diff --git a/flightdeck/html/crdconfig.go b/flightdeck/html/crdconfig.go
new file mode 100644
index 0000000..df6d437
--- /dev/null
+++ b/flightdeck/html/crdconfig.go
@@ -0,0 +1,667 @@
+package html
+
+import (
+ "fmt"
+ "reflect"
+ "slices"
+ "strings"
+
+ positcov1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1"
+ "github.com/posit-dev/team-operator/flightdeck/internal"
+ . "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/html"
+)
+
+// CRDConfigPage generates a configuration page from the Site CRD using reflection
+func CRDConfigPage(site *positcov1beta1.Site, config *internal.ServerConfig) Node {
+ return page("Config", config,
+ Main(
+ Class("max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8"),
+ H2(Text("Site Configuration"), Class("text-3xl font-bold text-gray-800 dark:text-white mb-4")),
+ Div(
+ Class("text-sm text-gray-600 dark:text-gray-400 mb-6"),
+ Text("This configuration is auto-generated from the Site Custom Resource Definition"),
+ ),
+ renderSiteSpec(&site.Spec),
+ ),
+ )
+}
+
+// renderSiteSpec renders the SiteSpec using reflection
+func renderSiteSpec(spec *positcov1beta1.SiteSpec) Node {
+ return renderStruct(reflect.ValueOf(spec).Elem(), "SiteSpec", 0)
+}
+
+// renderStruct recursively renders a struct and its fields
+func renderStruct(v reflect.Value, name string, depth int) Node {
+ if !v.IsValid() || v.Kind() != reflect.Struct {
+ return nil
+ }
+
+ var nodes []Node
+ t := v.Type()
+
+ // Add section header if not at root level
+ if depth > 0 {
+ headerClass := fmt.Sprintf("text-%s font-bold text-gray-800 dark:text-white mb-2", getHeaderSize(depth))
+ nodes = append(nodes, H3(Text(formatFieldName(name)), Class(headerClass)))
+ }
+
+ // Group fields by category for better organization
+ basicFields := []Node{}
+ productFields := []Node{}
+ advancedFields := []Node{}
+
+ for i := 0; i < v.NumField(); i++ {
+ field := v.Field(i)
+ fieldType := t.Field(i)
+
+ // Skip unexported fields
+ if !fieldType.IsExported() {
+ continue
+ }
+
+ // Get field name from JSON tag if available
+ fieldName := fieldType.Name
+ if jsonTag := fieldType.Tag.Get("json"); jsonTag != "" {
+ parts := strings.Split(jsonTag, ",")
+ if parts[0] != "" && parts[0] != "-" {
+ fieldName = parts[0]
+ }
+ // Skip fields marked as omitempty if they're empty
+ if len(parts) > 1 && strings.Contains(parts[1], "omitempty") && isZeroValue(field) {
+ continue
+ }
+ }
+
+ // Skip internal or empty structs
+ if isInternalField(fieldName) || (field.Kind() == reflect.Struct && isEmptyStruct(field)) {
+ continue
+ }
+
+ // For product fields at root level, render them differently
+ if depth == 0 && isProductField(fieldName) {
+ // Skip empty product structs
+ if field.Kind() == reflect.Struct && isEmptyStruct(field) {
+ continue
+ }
+ // Render product as a separate card section
+ productSection := renderProductSection(field, fieldName)
+ if productSection != nil {
+ productFields = append(productFields, productSection)
+ }
+ } else {
+ // Regular field rendering
+ row := renderField(field, fieldName, fieldType.Type, depth+1)
+ if row != nil {
+ // Categorize fields
+ if isAdvancedField(fieldName) {
+ advancedFields = append(advancedFields, row)
+ } else {
+ basicFields = append(basicFields, row)
+ }
+ }
+ }
+ }
+
+ // Render sections
+ if depth == 0 {
+ // At root level, organize into sections
+ if len(basicFields) > 0 {
+ nodes = append(nodes,
+ Div(
+ Class("mb-8"),
+ H3(Text("Basic Configuration"), Class("text-2xl font-bold text-gray-800 dark:text-white mb-4")),
+ Div(
+ Class("bg-white dark:bg-neutral-800 rounded-md border border-posit-border dark:border-neutral-700 overflow-hidden"),
+ Table(
+ Class("table-auto w-full"),
+ TBody(basicFields...),
+ ),
+ ),
+ ),
+ )
+ }
+ if len(productFields) > 0 {
+ nodes = append(nodes,
+ Div(
+ Class("mb-8"),
+ H3(Text("Product Configuration"), Class("text-2xl font-bold text-gray-800 dark:text-white mb-4")),
+ Div(Class("space-y-6"), Group(productFields)),
+ ),
+ )
+ }
+ if len(advancedFields) > 0 {
+ nodes = append(nodes,
+ Div(
+ Class("mb-8"),
+ H3(Text("Advanced Configuration"), Class("text-2xl font-bold text-gray-800 dark:text-white mb-4")),
+ Div(
+ Class("bg-white dark:bg-neutral-800 rounded-md border border-posit-border dark:border-neutral-700 overflow-hidden"),
+ Table(
+ Class("table-auto w-full"),
+ TBody(advancedFields...),
+ ),
+ ),
+ ),
+ )
+ }
+ } else {
+ // For nested structs, render all fields together
+ allFields := slices.Concat(basicFields, productFields, advancedFields)
+ if len(allFields) > 0 {
+ nodes = append(nodes,
+ Table(
+ Class("table-auto w-full"),
+ TBody(allFields...),
+ ),
+ )
+ }
+ }
+
+ return Div(Class("space-y-6"), Group(nodes))
+}
+
+// renderNestedStruct renders a nested struct as a clean subsection
+func renderNestedStruct(v reflect.Value, name string, depth int) Node {
+ if !v.IsValid() || v.Kind() != reflect.Struct {
+ return nil
+ }
+
+ var fieldItems []Node
+ t := v.Type()
+
+ for i := 0; i < v.NumField(); i++ {
+ field := v.Field(i)
+ fieldType := t.Field(i)
+
+ // Skip unexported fields
+ if !fieldType.IsExported() {
+ continue
+ }
+
+ // Get field name from JSON tag if available
+ fieldName := fieldType.Name
+ if jsonTag := fieldType.Tag.Get("json"); jsonTag != "" {
+ parts := strings.Split(jsonTag, ",")
+ if parts[0] != "" && parts[0] != "-" {
+ fieldName = parts[0]
+ }
+ // Skip fields marked as omitempty if they're empty
+ if len(parts) > 1 && strings.Contains(parts[1], "omitempty") && isZeroValue(field) {
+ continue
+ }
+ }
+
+ // Skip internal or empty fields
+ if isInternalField(fieldName) || isZeroValue(field) {
+ continue
+ }
+
+ formattedName := formatFieldName(fieldName)
+
+ // Dereference pointer fields
+ actualField := field
+ if actualField.Kind() == reflect.Ptr {
+ if actualField.IsNil() {
+ continue
+ }
+ actualField = actualField.Elem()
+ }
+
+ // Render the field value
+ var valueNode Node
+ switch actualField.Kind() {
+ case reflect.String:
+ valueNode = Span(Text(actualField.String()), Class("font-mono text-sm"))
+ case reflect.Int, reflect.Int32, reflect.Int64:
+ valueNode = Span(Text(fmt.Sprintf("%d", actualField.Int())), Class("font-mono text-sm"))
+ case reflect.Bool:
+ if actualField.Bool() {
+ valueNode = Span(Text("true"), Class("font-mono text-sm"))
+ }
+ case reflect.Map:
+ if actualField.Len() > 0 {
+ valueNode = renderMap(actualField)
+ }
+ case reflect.Slice:
+ if actualField.Len() > 0 {
+ valueNode = renderSlice(actualField, depth+1)
+ }
+ case reflect.Struct:
+ // Check for Raw JSON struct first
+ display := formatValue(actualField)
+ if display != "" && display != fmt.Sprintf("%v", actualField.Interface()) {
+ valueNode = Span(Text(display), Class("font-mono text-sm"))
+ } else {
+ valueNode = renderNestedStruct(actualField, fieldName, depth+1)
+ }
+ default:
+ valueNode = Span(Text(formatValue(actualField)), Class("font-mono text-sm"))
+ }
+
+ if valueNode != nil {
+ fieldItems = append(fieldItems,
+ Div(
+ Class("flex flex-col sm:flex-row gap-2 py-2"),
+ Div(Text(formattedName+":"), Class("font-semibold text-gray-700 dark:text-gray-300 min-w-[150px]")),
+ Div(valueNode, Class("text-gray-800 dark:text-white")),
+ ),
+ )
+ }
+ }
+
+ if len(fieldItems) == 0 {
+ return nil
+ }
+
+ return Div(
+ Class("bg-gray-50 dark:bg-neutral-900 rounded p-3 space-y-1"),
+ Group(fieldItems),
+ )
+}
+
+// renderProductSection renders a product configuration as a card-style section
+func renderProductSection(v reflect.Value, name string) Node {
+ if !v.IsValid() || (v.Kind() == reflect.Struct && isEmptyStruct(v)) {
+ return nil
+ }
+
+ formattedName := formatFieldName(name)
+
+ // Collect all non-empty fields from the product struct
+ var fieldRows []Node
+ t := v.Type()
+
+ for i := 0; i < v.NumField(); i++ {
+ field := v.Field(i)
+ fieldType := t.Field(i)
+
+ // Skip unexported fields
+ if !fieldType.IsExported() {
+ continue
+ }
+
+ // Get field name from JSON tag if available
+ fieldName := fieldType.Name
+ if jsonTag := fieldType.Tag.Get("json"); jsonTag != "" {
+ parts := strings.Split(jsonTag, ",")
+ if parts[0] != "" && parts[0] != "-" {
+ fieldName = parts[0]
+ }
+ // Skip fields marked as omitempty if they're empty
+ if len(parts) > 1 && strings.Contains(parts[1], "omitempty") && isZeroValue(field) {
+ continue
+ }
+ }
+
+ // Skip internal fields
+ if isInternalField(fieldName) {
+ continue
+ }
+
+ // Render the field
+ row := renderField(field, fieldName, fieldType.Type, 1)
+ if row != nil {
+ fieldRows = append(fieldRows, row)
+ }
+ }
+
+ if len(fieldRows) == 0 {
+ return nil
+ }
+
+ // Return a card-style section for this product
+ return Div(
+ Class("bg-white dark:bg-neutral-800 rounded-md border border-posit-border dark:border-neutral-700 p-6"),
+ H4(Text(formattedName), Class("text-xl font-bold text-gray-800 dark:text-white mb-4")),
+ Div(
+ Class("overflow-x-auto"),
+ Table(
+ Class("table-auto w-full"),
+ TBody(fieldRows...),
+ ),
+ ),
+ )
+}
+
+// renderField renders a single field as a table row or nested structure
+func renderField(v reflect.Value, name string, t reflect.Type, depth int) Node {
+ if !v.IsValid() || isZeroValue(v) {
+ return nil
+ }
+
+ formattedName := formatFieldName(name)
+
+ switch v.Kind() {
+ case reflect.String:
+ value := v.String()
+ if value == "" {
+ return nil
+ }
+ return createFieldRow(formattedName, value)
+
+ case reflect.Int, reflect.Int32, reflect.Int64:
+ if v.Int() == 0 {
+ return nil
+ }
+ return createFieldRow(formattedName, fmt.Sprintf("%d", v.Int()))
+
+ case reflect.Bool:
+ // Only show bool fields if they're true
+ if v.Bool() {
+ return createFieldRow(formattedName, "true")
+ }
+ return nil
+
+ case reflect.Map:
+ if v.Len() == 0 {
+ return nil
+ }
+ return createExpandableField(formattedName, renderMap(v))
+
+ case reflect.Slice:
+ if v.Len() == 0 {
+ return nil
+ }
+ return createExpandableField(formattedName, renderSlice(v, depth))
+
+ case reflect.Struct:
+ // For nested structs, render them as a nested section
+ nestedContent := renderNestedStruct(v, name, depth)
+ if nestedContent == nil {
+ return nil
+ }
+ return createExpandableField(formattedName, nestedContent)
+
+ case reflect.Ptr:
+ if v.IsNil() {
+ return nil
+ }
+ return renderField(v.Elem(), name, t.Elem(), depth)
+
+ default:
+ // For other types, show the value as string if possible
+ return createFieldRow(formattedName, fmt.Sprintf("%v", v.Interface()))
+ }
+}
+
+// createFieldRow creates a simple table row for a field
+func createFieldRow(name, value string) Node {
+ return Tr(
+ Td(Text(name), Class("text-left font-semibold text-gray-700 dark:text-gray-300 border-b border-posit-border dark:border-neutral-700 p-3")),
+ Td(Text(value), Class("text-left text-gray-800 dark:text-white border-b border-posit-border dark:border-neutral-700 p-3 font-mono text-sm")),
+ )
+}
+
+// createExpandableField creates a row with expandable content
+func createExpandableField(name string, content Node) Node {
+ if content == nil {
+ return nil
+ }
+
+ return Tr(
+ Td(Text(name), Class("text-left font-semibold text-gray-700 dark:text-gray-300 border-b border-posit-border dark:border-neutral-700 p-3 align-top")),
+ Td(content, Class("text-left text-gray-800 dark:text-white border-b border-posit-border dark:border-neutral-700 p-3")),
+ )
+}
+
+// formatValue extracts a display string from a reflect.Value,
+// handling special types like apiextensionsv1.JSON that contain raw JSON bytes,
+// and dereferencing pointer types to their underlying values.
+func formatValue(v reflect.Value) string {
+ v = deref(v)
+ if !v.IsValid() {
+ return ""
+ }
+
+ // Check for structs with a Raw []byte field (e.g., apiextensionsv1.JSON)
+ if v.Kind() == reflect.Struct {
+ rawField := v.FieldByName("Raw")
+ if rawField.IsValid() && rawField.Kind() == reflect.Slice && rawField.Type().Elem().Kind() == reflect.Uint8 {
+ raw := rawField.Bytes()
+ if len(raw) > 0 {
+ s := strings.TrimSpace(string(raw))
+ // Strip surrounding quotes from JSON strings
+ if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
+ s = s[1 : len(s)-1]
+ }
+ return s
+ }
+ return ""
+ }
+ }
+
+ return fmt.Sprintf("%v", v.Interface())
+}
+
+// deref dereferences pointers and interfaces to their underlying value.
+func deref(v reflect.Value) reflect.Value {
+ for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
+ if v.IsNil() {
+ return reflect.Value{}
+ }
+ v = v.Elem()
+ }
+ return v
+}
+
+// renderMap renders a map, recursively expanding struct values
+func renderMap(v reflect.Value) Node {
+ if v.Len() == 0 {
+ return nil
+ }
+
+ var items []Node
+ for _, key := range v.MapKeys() {
+ val := deref(v.MapIndex(key))
+ if !val.IsValid() {
+ continue
+ }
+
+ keyStr := fmt.Sprintf("%v", key.Interface())
+
+ // If the value is a struct, check if it's a raw JSON type first
+ if val.Kind() == reflect.Struct {
+ // Try formatValue first — handles apiextensionsv1.JSON and similar
+ if display := formatValue(val); display != "" && display != fmt.Sprintf("%v", val.Interface()) {
+ items = append(items,
+ Div(
+ Class("flex gap-2 py-1"),
+ Span(Text(keyStr+":"), Class("font-semibold text-gray-700 dark:text-gray-300")),
+ Span(Text(display), Class("font-mono text-sm text-gray-800 dark:text-white")),
+ ),
+ )
+ continue
+ }
+ // Otherwise render recursively
+ nested := renderNestedStruct(val, keyStr, 2)
+ if nested != nil {
+ items = append(items,
+ Div(
+ Class("py-2"),
+ Div(Text(keyStr+":"), Class("font-semibold text-gray-700 dark:text-gray-300 mb-1")),
+ nested,
+ ),
+ )
+ continue
+ }
+ }
+
+ // For scalar values, use formatValue
+ display := formatValue(val)
+ if display == "" {
+ continue
+ }
+ items = append(items,
+ Div(
+ Class("flex gap-2 py-1"),
+ Span(Text(keyStr+":"), Class("font-semibold text-gray-700 dark:text-gray-300")),
+ Span(Text(display), Class("font-mono text-sm text-gray-800 dark:text-white")),
+ ),
+ )
+ }
+
+ if len(items) == 0 {
+ return nil
+ }
+ return Div(Class("bg-gray-50 dark:bg-neutral-900 rounded p-2 space-y-1"), Group(items))
+}
+
+// renderSlice renders a slice as a list
+func renderSlice(v reflect.Value, depth int) Node {
+ if v.Len() == 0 {
+ return nil
+ }
+
+ var items []Node
+ isListOfPrimitives := false
+
+ for i := 0; i < v.Len(); i++ {
+ elem := v.Index(i)
+
+ // For slices of structs, render each struct
+ if elem.Kind() == reflect.Struct {
+ items = append(items,
+ Div(
+ Class("bg-gray-50 dark:bg-neutral-900 rounded p-3 mb-2"),
+ H5(Text(fmt.Sprintf("Item %d", i+1)), Class("font-semibold text-gray-700 dark:text-gray-300 mb-2")),
+ renderNestedStruct(elem, fmt.Sprintf("Item %d", i+1), depth+1),
+ ),
+ )
+ } else {
+ // For primitive types, render as list items
+ isListOfPrimitives = true
+ items = append(items,
+ Li(Text(fmt.Sprintf("%v", elem.Interface())), Class("font-mono text-sm text-gray-800 dark:text-white")),
+ )
+ }
+ }
+
+ if isListOfPrimitives {
+ return Div(
+ Class("bg-gray-50 dark:bg-neutral-900 rounded p-2"),
+ Ul(Class("list-disc list-inside space-y-1"), Group(items)),
+ )
+ }
+ return Div(Class("space-y-2"), Group(items))
+}
+
+// isZeroValue checks if a reflect.Value is the zero value for its type
+func isZeroValue(v reflect.Value) bool {
+ if !v.IsValid() {
+ return true
+ }
+
+ switch v.Kind() {
+ case reflect.String:
+ return v.String() == ""
+ case reflect.Int, reflect.Int32, reflect.Int64:
+ return v.Int() == 0
+ case reflect.Bool:
+ return !v.Bool()
+ case reflect.Map, reflect.Slice:
+ return v.Len() == 0
+ case reflect.Struct:
+ // Check if all fields are zero values
+ for i := 0; i < v.NumField(); i++ {
+ if !isZeroValue(v.Field(i)) {
+ return false
+ }
+ }
+ return true
+ case reflect.Ptr, reflect.Interface:
+ return v.IsNil()
+ }
+ return false
+}
+
+// isEmptyStruct checks if a struct has no exported non-zero fields
+func isEmptyStruct(v reflect.Value) bool {
+ if v.Kind() != reflect.Struct {
+ return false
+ }
+
+ t := v.Type()
+ for i := 0; i < v.NumField(); i++ {
+ if t.Field(i).IsExported() && !isZeroValue(v.Field(i)) {
+ return false
+ }
+ }
+ return true
+}
+
+// isInternalField checks if a field name indicates it's internal
+func isInternalField(name string) bool {
+ // Skip Kubernetes internal fields
+ return strings.HasPrefix(name, "XXX_") ||
+ name == "TypeMeta" ||
+ name == "ObjectMeta" ||
+ strings.HasSuffix(name, "_")
+}
+
+// isProductField checks if a field is a product configuration
+func isProductField(name string) bool {
+ // NOTE: This list is hardcoded. New product fields will default to the basic category
+ // unless explicitly added here. This is intentional to ensure new fields are at least
+ // visible rather than being lost.
+ productNames := []string{
+ "workbench", "connect", "packageManager", "chronicle", "flightdeck",
+ }
+ for _, product := range productNames {
+ if name == product {
+ return true
+ }
+ }
+ return false
+}
+
+// isAdvancedField checks if a field is an advanced configuration
+func isAdvancedField(name string) bool {
+ // NOTE: This list is hardcoded. New advanced fields will default to the basic category
+ // unless explicitly added here. This ensures unknown fields are visible by default
+ // rather than hidden in an advanced section.
+ advancedNames := []string{
+ "secret", "workloadSecret", "mainDatabaseCredentialSecret",
+ "volumeSource", "volumeSubdirJobOff", "dropDatabaseOnTearDown",
+ "debug", "logFormat", "networkTrust", "imagePullSecrets",
+ "ingressAnnotations", "extraSiteServiceAccounts",
+ }
+ for _, advanced := range advancedNames {
+ if name == advanced {
+ return true
+ }
+ }
+ return false
+}
+
+// formatFieldName converts a field name to a more readable format
+func formatFieldName(name string) string {
+ // Add spaces before capitals (camelCase to Title Case)
+ var result strings.Builder
+ for i, r := range name {
+ if i > 0 && r >= 'A' && r <= 'Z' {
+ prev := name[i-1]
+ if prev >= 'a' && prev <= 'z' {
+ result.WriteString(" ")
+ }
+ }
+ result.WriteRune(r)
+ }
+
+ return result.String()
+}
+
+// getHeaderSize returns the appropriate header size based on depth
+func getHeaderSize(depth int) string {
+ switch depth {
+ case 1:
+ return "2xl"
+ case 2:
+ return "xl"
+ case 3:
+ return "lg"
+ default:
+ return "base"
+ }
+}
diff --git a/flightdeck/html/crdconfig_test.go b/flightdeck/html/crdconfig_test.go
new file mode 100644
index 0000000..aefa174
--- /dev/null
+++ b/flightdeck/html/crdconfig_test.go
@@ -0,0 +1,139 @@
+package html
+
+import (
+ "strings"
+ "testing"
+
+ positcov1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1"
+ "github.com/posit-dev/team-operator/flightdeck/internal"
+ corev1 "k8s.io/api/core/v1"
+)
+
+func TestCRDConfigPage(t *testing.T) {
+ // Create a sample Site with various configurations
+ site := &positcov1beta1.Site{
+ Spec: positcov1beta1.SiteSpec{
+ Domain: "example.com",
+ AwsAccountId: "123456789012",
+ ClusterDate: "20250101",
+ WorkloadCompoundName: "test-workload",
+ IngressClass: "nginx",
+ SharedDirectory: "shared",
+ PackageManagerUrl: "https://pm.example.com",
+ EFSEnabled: true,
+ VPCCIDR: "10.0.0.0/16",
+ Workbench: positcov1beta1.InternalWorkbenchSpec{
+ Image: "rstudio/workbench:latest",
+ Replicas: 2,
+ ImagePullPolicy: corev1.PullAlways,
+ },
+ Connect: positcov1beta1.InternalConnectSpec{
+ Image: "rstudio/connect:latest",
+ Replicas: 3,
+ },
+ PackageManager: positcov1beta1.InternalPackageManagerSpec{
+ Image: "rstudio/pm:latest",
+ Replicas: 1,
+ },
+ },
+ }
+
+ config := &internal.ServerConfig{
+ SiteName: "test-site",
+ Namespace: "posit-team",
+ ShowConfig: true,
+ }
+
+ // Render the page
+ page := CRDConfigPage(site, config)
+
+ // Convert to string for testing
+ var buf strings.Builder
+ err := page.Render(&buf)
+ if err != nil {
+ t.Fatalf("Failed to render page: %v", err)
+ }
+ html := buf.String()
+
+ // Check that the page contains expected content
+ expectedStrings := []string{
+ "Site Configuration",
+ "auto-generated from the Site Custom Resource Definition",
+ "example.com",
+ "Basic Configuration",
+ "Product Configuration",
+ "workbench",
+ "connect",
+ "package Manager", // Note: formatting adds space
+ }
+
+ for _, expected := range expectedStrings {
+ if !strings.Contains(html, expected) {
+ t.Errorf("Expected HTML to contain '%s', but it didn't", expected)
+ }
+ }
+
+ // Check that zero values are not rendered
+ unexpectedStrings := []string{
+ "chronicle", // Not set, should be omitted
+ }
+
+ for _, unexpected := range unexpectedStrings {
+ if strings.Contains(html, unexpected) {
+ t.Errorf("Expected HTML to NOT contain '%s', but it did", unexpected)
+ }
+ }
+}
+
+func TestFormatFieldName(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"awsAccountId", "aws Account Id"},
+ {"VPCCIDR", "VPCCIDR"},
+ {"packageManager", "package Manager"},
+ {"enableFqdnHealthChecks", "enable Fqdn Health Checks"},
+ }
+
+ for _, test := range tests {
+ result := formatFieldName(test.input)
+ if result != test.expected {
+ t.Errorf("formatFieldName(%s) = %s, expected %s", test.input, result, test.expected)
+ }
+ }
+}
+
+func TestIsProductField(t *testing.T) {
+ productFields := []string{"workbench", "connect", "packageManager", "chronicle", "flightdeck"}
+ nonProductFields := []string{"domain", "secret", "debug"}
+
+ for _, field := range productFields {
+ if !isProductField(field) {
+ t.Errorf("Expected %s to be a product field", field)
+ }
+ }
+
+ for _, field := range nonProductFields {
+ if isProductField(field) {
+ t.Errorf("Expected %s to NOT be a product field", field)
+ }
+ }
+}
+
+func TestIsAdvancedField(t *testing.T) {
+ advancedFields := []string{"secret", "workloadSecret", "debug", "networkTrust"}
+ basicFields := []string{"domain", "awsAccountId", "clusterDate"}
+
+ for _, field := range advancedFields {
+ if !isAdvancedField(field) {
+ t.Errorf("Expected %s to be an advanced field", field)
+ }
+ }
+
+ for _, field := range basicFields {
+ if isAdvancedField(field) {
+ t.Errorf("Expected %s to NOT be an advanced field", field)
+ }
+ }
+}
diff --git a/flightdeck/http/server.go b/flightdeck/http/server.go
index 0e1feac..ad2a60c 100644
--- a/flightdeck/http/server.go
+++ b/flightdeck/http/server.go
@@ -121,7 +121,7 @@ func Config(mux *http.ServeMux, getter internal.SiteInterface, config *internal.
return nil, err
}
slog.Debug("rendering config page", "site", config.SiteName)
- return html.SiteConfigPage(site, config), nil
+ return html.CRDConfigPage(site, config), nil
}))
}