Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Custom Field Configuration Editor for Shoot Clusters #1926

Merged
merged 15 commits into from
Jun 27, 2024
Merged
Binary file modified docs/images/custom-fields-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/custom-fields-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/custom-fields-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/custom-fields-4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/custom-fields-5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/custom-fields-6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/custom-fields-7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 28 additions & 7 deletions docs/usage/custom-fields.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
# Custom Shoot Fields

The Dashboard supports custom shoot fields, that can be defined per project by specifying `metadata.annotations["dashboard.gardener.cloud/shootCustomFields"]`.
The fields can be configured to be displayed on the cluster list and cluster details page.
Custom fields do not show up on the `ALL_PROJECTS` page.
The Dashboard supports custom shoot fields, which can be configured to be displayed on the cluster list and cluster details page. Custom fields do not show up on the `ALL_PROJECTS` page.

## Project administration page:
Each custom field configuration is shown with it's own chip.
Each custom field configuration is shown with its own chip.

<img width="800" src="../images/custom-fields-1.png">

Expand All @@ -32,16 +30,39 @@ Custom fields can be shown in a dedicated card (`Custom Fields`) on the cluster
| defaultValue | String/Number | | | Default value, in case there is no value for the given `path` |
| showColumn | Bool | true | | Field shall appear as column in the cluster list |
| columnSelectedByDefault | Bool | true | | Indicates if field shall be selected by default on the cluster list (not hidden by default) |
| weight | Number | 0 | | Defines the order of the column. The standard columns start with weight 100 and continue in 100 increments (200, 300, ..) |
| weight | Number | 0 | | Defines the order of the column. The built-in columns start with a weight of 100, increasing by 100 (200, 300, etc.) |
| sortable | Bool | true | | Indicates if column is sortable on the cluster list |
| searchable | Bool | true | | Indicates if column is searchable on the cluster list |
| showDetails | Bool | true | | Indicates if field shall appear in a dedicated card (`Custom Fields`) on the cluster details page |

As there is currently no way to configure the custom shoot fields for a project in the gardener dashboard, you have to use `kubectl` to update the `project` resource. See [Project Operations](./project-operations.md#download-kubeconfig-for-a-user) on how to get a `kubeconfig` for the `garden` cluster in order to edit the `project`.
## Editor for Custom Shoot Fields

The Gardener Dashboard now includes an editor for custom shoot fields, allowing users to configure these fields directly from the dashboard without needing to use `kubectl`. This editor can be accessed from the project administration page.

### Accessing the Editor

1. Navigate to the project administration page.
2. Scroll down to the `Custom Fields for Shoots` section.
3. Click on the gear icon to open the configuration panel for custom fields.

<img width="800" src="../images/custom-fields-5.png">

### Adding a New Custom Field


1. In the `Configure Custom Fields for Shoot Clusters` panel, click on the `+ ADD NEW FIELD` button.

<img width="800" src="../images/custom-fields-6.png">

2. Fill in the details for the new custom field in the `Add New Field` form. Refer to the [Configuration](#configuration) section for detailed descriptions of each field.

3. Click the `ADD` button to save the new custom field.

<img width="800" src="../images/custom-fields-7.png">

### Example

The following is an example project yaml:
Custom shoot fields can be defined per project by specifying `metadata.annotations["dashboard.gardener.cloud/shootCustomFields"]`. The following is an example project yaml:
```yaml
apiVersion: core.gardener.cloud/v1beta1
kind: Project
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`useProjectShootCustomFields > should add a custom field 1`] = `
[
{
"columnSelectedByDefault": true,
"name": "Field1",
"path": "path1",
"searchable": true,
"showColumn": true,
"showDetails": true,
"sortable": true,
"weight": 0,
},
]
`;

exports[`useProjectShootCustomFields > should ensure the key is not part of the rendered JSON.stringify annotation when adding a new entry 1`] = `"[{"name":"Field1","path":"path1"}]"`;

exports[`useProjectShootCustomFields > should initialize shootCustomFields correctly with legacy data 1`] = `
[
{
"columnSelectedByDefault": true,
"defaultValue": "unknown",
"icon": "mdi-heart-pulse",
"name": "Shoot Status",
"path": "metadata.labels["shoot.gardener.cloud/status"]",
"searchable": true,
"showColumn": true,
"showDetails": true,
"sortable": true,
"tooltip": "Indicates the health status of the cluster",
"weight": 950,
},
{
"columnSelectedByDefault": true,
"icon": "mdi-table-network",
"name": "Networking Type",
"path": "spec.networking.type",
"searchable": true,
"showColumn": false,
"showDetails": true,
"sortable": true,
"weight": 0,
},
]
`;

exports[`useProjectShootCustomFields > should skip custom fields with invalid values 1`] = `
[
{
"columnSelectedByDefault": true,
"name": "Valid Field",
"path": "path1",
"searchable": true,
"showColumn": true,
"showDetails": true,
"sortable": true,
"weight": 0,
},
]
`;
218 changes: 218 additions & 0 deletions frontend/__tests__/composables/useProjectShootCustomFields.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors
//
// SPDX-License-Identifier: Apache-2.0
//

import {
ref,
reactive,
} from 'vue'
import {
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest'

import { useProjectShootCustomFields } from '@/composables/useProjectShootCustomFields'
import {
formatValue,
isCustomField,
} from '@/composables/useProjectShootCustomFields/helper'

// Mock dependencies
vi.mock('@/composables/useLogger', () => ({
useLogger: () => ({
error: vi.fn(),
}),
}))

let annotations

beforeEach(() => {
annotations = reactive({})
})

vi.mock('@/composables/useProjectMetadata', () => ({
useProjectMetadata: projectItem => ({
getProjectAnnotation: vi.fn(key => annotations[key] || null),
setProjectAnnotation: vi.fn((key, value) => {
annotations[key] = value
}),
unsetProjectAnnotation: vi.fn(key => {
delete annotations[key]
}),
}),
}))

describe('useProjectShootCustomFields', () => {
let projectItem
let options

beforeEach(() => {
projectItem = ref({})
options = {}
})

it('should initialize shootCustomFields correctly with legacy data', () => {
annotations['dashboard.gardener.cloud/shootCustomFields'] = JSON.stringify({
shootStatus: {
name: 'Shoot Status',
path: 'metadata.labels["shoot.gardener.cloud/status"]',
icon: 'mdi-heart-pulse',
tooltip: 'Indicates the health status of the cluster',
defaultValue: 'unknown',
showColumn: true,
columnSelectedByDefault: true,
weight: 950,
searchable: true,
sortable: true,
showDetails: true,
},
networking: {
name: 'Networking Type',
path: 'spec.networking.type',
icon: 'mdi-table-network',
showColumn: false,
},
})
const { shootCustomFields } = useProjectShootCustomFields(projectItem, options)
expect(shootCustomFields.value.length).toBe(2)
expect(shootCustomFields.value).toMatchSnapshot()
})

it('should add a custom field', async () => {
const { shootCustomFields, addShootCustomField } = useProjectShootCustomFields(projectItem, options)
const customField = { name: 'Field1', path: 'path1' }
expect(shootCustomFields.value.length).toBe(0)
addShootCustomField(customField)
expect(shootCustomFields.value.length).toBe(1)
expect(shootCustomFields.value).toMatchSnapshot()
})

it('should delete a custom field', async () => {
const { shootCustomFields, addShootCustomField, deleteShootCustomField } = useProjectShootCustomFields(projectItem, options)
const customField = { name: 'Field1', path: 'path1' }
addShootCustomField(customField)
expect(shootCustomFields.value.length).toBe(1)
deleteShootCustomField(customField)
expect(shootCustomFields.value.length).toBe(0)
})

it('should replace a custom field', async () => {
const { shootCustomFields, addShootCustomField, replaceShootCustomField } = useProjectShootCustomFields(projectItem, options)
const oldCustomField = { name: 'Field1', path: 'path1' }
const newCustomField = { name: 'Field2', path: 'path2' }
addShootCustomField(oldCustomField)
expect(shootCustomFields.value.length).toBe(1)
replaceShootCustomField(oldCustomField, newCustomField)
expect(shootCustomFields.value.length).toBe(1)
expect(shootCustomFields.value).toContainEqual(expect.objectContaining(newCustomField))
expect(shootCustomFields.value).not.toContainEqual(expect.objectContaining(oldCustomField))
})

it('should check if custom field name is unique', async () => {
const { addShootCustomField, isShootCustomFieldNameUnique } = useProjectShootCustomFields(projectItem, options)
const customField = { name: 'Field1', path: 'path1' }
addShootCustomField(customField)
expect(isShootCustomFieldNameUnique('Field1')).toBe(false)
expect(isShootCustomFieldNameUnique('Field2')).toBe(true)
})

it('should get custom field by key', async () => {
const { addShootCustomField, getCustomFieldByKey } = useProjectShootCustomFields(projectItem, options)
const customField = { name: 'Field1', path: 'path1' }
addShootCustomField(customField)
addShootCustomField({ name: 'Field2', path: 'path2' })
const key = 'Z_field1'
const result = getCustomFieldByKey(key)
expect(result).toEqual(expect.objectContaining(customField))
})

it('should handle empty custom fields', async () => {
const { shootCustomFields, rawShootCustomFields } = useProjectShootCustomFields(projectItem, options)
rawShootCustomFields.value = null
expect(shootCustomFields.value).toEqual([])
})

it('should handle invalid JSON in custom fields', async () => {
const { shootCustomFields, rawShootCustomFields } = useProjectShootCustomFields(projectItem, options)
rawShootCustomFields.value = 'invalid JSON'
expect(shootCustomFields.value).toEqual([])
})

it('should generate key from name correctly', () => {
const { generateKeyFromName } = useProjectShootCustomFields(projectItem, options)
const name = 'Custom Field Name'
const key = generateKeyFromName(name)
expect(key).toBe('Z_customFieldName')
})

it('should skip custom fields with invalid values', () => {
annotations = reactive({
'dashboard.gardener.cloud/shootCustomFields': JSON.stringify([
{ name: 'Valid Field', path: 'path1' },
{ name: 'Invalid Field1', path: 'path2', value: { key: 'value' } }, // ignored - no objects allowed as values of custom field properties
{ name: 'Invalid Field2', path: { foo: 'bar' } }, // no objects allowed as values of custom field properties
{ name: 'Invalid Field3' }, // ignored, missing required property path
{ path: 'path3' }, // ignored, missing required property name
{}, // ignored
null, // ignored
]),
})

const { shootCustomFields } = useProjectShootCustomFields(projectItem, options)
expect(shootCustomFields.value.length).toBe(1)
expect(shootCustomFields.value).toMatchSnapshot()
})

it('should ensure the key is not part of the rendered JSON.stringify annotation when adding a new entry', () => {
const { shootCustomFields, addShootCustomField } = useProjectShootCustomFields(projectItem, options)
const customField = { name: 'Field1', path: 'path1' }

// Add the custom field
addShootCustomField(customField)

// Verify the key is set correctly
const addedField = shootCustomFields.value.find(field => field.name === 'Field1')
expect(addedField.key).toBe('Z_field1')

// Verify the key is not part of the JSON stringified output in annotations
const annotationString = annotations['dashboard.gardener.cloud/shootCustomFields']
expect(annotationString).not.toContain('key')
expect(annotationString).toMatchSnapshot()
})

describe('helper', () => {
describe('formatValue', () => {
it('should join array elements with a separator', () => {
const result = formatValue(['a', 'b', 'c'], ',')
expect(result).toBe('a,b,c')
})

it('should return the value as is for non-array values', () => {
const result = formatValue('string', ',')
expect(result).toBe('string')
})

it('should return undefined for object values', () => {
const result = formatValue({ key: 'value' }, ',')
expect(result).toBeUndefined()
})
})

describe('isCustomField', () => {
it('should return true for keys starting with "Z_"', () => {
const result = isCustomField('Z_customField')
expect(result).toBe(true)
})

it('should return false for keys not starting with "Z_"', () => {
const result = isCustomField('customField')
expect(result).toBe(false)
})
})
})
})
Loading