Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ Tools that write a new temporary kubeconfig for each shell produce a new key
each time; launch Radar with `--kubeconfig` pointing at the stable file instead.
That section groups saved integrations by kubeconfig entry; removal is offered
for entries no longer loaded, with a confirmation for the selected integration.
Each integration reads as saved, or as auto-discovery (Cost: Automatic) when
it was switched back and no endpoint, credential or cluster mapping is left.
Overview's collapsed **Configuration files** section explains the three local files and
credential storage; the info button beside the cluster name in each integration
tab identifies its kubeconfig entry. In-cluster installations show operator
Expand Down
3 changes: 3 additions & 0 deletions internal/connections/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ type StoredSettingsView struct {
InFileName string `json:"inFileName,omitempty"`
Availability string `json:"availability"`
Revision string `json:"revision"`
Mode string `json:"mode"`
URL string `json:"url"`
HeaderKeys []string `json:"headerKeys"`
EnvHeaderKeys []string `json:"envHeaderKeys"`
SecretSet bool `json:"secretSet"`
InsecureTLS bool `json:"insecureTls"`
ClusterID string `json:"clusterId"`
Error string `json:"error,omitempty"`
}

Expand Down Expand Up @@ -179,6 +181,7 @@ func (p *Resolver) Catalog() ([]StoredSettingsView, error) {
for kind, settings := range profile.Integrations {
v := settingsView(settings)
v.Binding, v.Integration, v.Context = binding, kind, profile.Context
v.Mode, v.ClusterID = settings.EffectiveMode(kind), settings.ClusterID
v.Source, v.InFileName = profile.Source, profile.InFileName
v.Revision = p.integrationRevision(file, kind, binding)
v.Availability = "unavailable"
Expand Down
11 changes: 10 additions & 1 deletion internal/connections/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ func TestDecliningPausedSettingsSwitchesToDiscovery(t *testing.T) {
r, a, _ := setupResolver(t)
apply(t, r, a, Update{Kind: config.IntegrationArgoCD, Action: "save", Secret: &SecretEdit{Action: "set", Value: "discovery-token"}})
apply(t, r, a, Update{Kind: config.IntegrationCost, Action: "save", URL: stringPtr("https://cost.example"), Secret: &SecretEdit{Action: "set", Value: "key"}, ClusterID: stringPtr("cluster-a")})
saved, err := r.Catalog()
if err != nil {
t.Fatal(err)
}
if !slices.ContainsFunc(saved, func(entry StoredSettingsView) bool {
return entry.Integration == config.IntegrationCost && entry.ClusterID == "cluster-a"
}) {
t.Fatalf("catalog omits the saved Kubecost cluster ID: %+v", saved)
}
a.Fingerprint = "changed"
for _, kind := range []config.Integration{config.IntegrationArgoCD, config.IntegrationCost} {
paused := r.Resolve(a, kind, false).View
Expand Down Expand Up @@ -286,7 +295,7 @@ func TestDecliningPausedSettingsSwitchesToDiscovery(t *testing.T) {
}
for _, kind := range []config.Integration{config.IntegrationArgoCD, config.IntegrationCost} {
if !slices.ContainsFunc(catalog, func(entry StoredSettingsView) bool {
return entry.Binding == a.Binding && entry.Integration == kind && entry.URL == "" && !entry.SecretSet
return entry.Binding == a.Binding && entry.Integration == kind && entry.Mode == "auto" && entry.URL == "" && !entry.SecretSet && entry.ClusterID == ""
}) {
t.Fatalf("declined %s missing from the catalog as an empty record: %+v", kind, catalog)
}
Expand Down
25 changes: 25 additions & 0 deletions packages/k8s-ui/src/components/ui/SelectMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'
import { SelectMenu } from './SelectMenu'

vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)

it('keeps one option tabbable when the options shrink while the menu is open', async () => {
const element = document.createElement('div')
document.body.append(element)
const root = createRoot(element)
const render = (options: { value: string; label: string }[]) =>
root.render(<SelectMenu value="c" onChange={() => {}} options={options} ariaLabel="Source" />)
await act(async () => render([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }, { value: 'c', label: 'C' }]))
await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!.click() })
expect(element.querySelectorAll('[role="option"]')).toHaveLength(3)

await act(async () => render([{ value: 'a', label: 'A' }]))
const tabbable = element.querySelectorAll('[role="option"][tabindex="0"]')
expect(tabbable).toHaveLength(1)
expect(tabbable[0].textContent).toBe('A')
await act(async () => root.unmount())
element.remove()
})
20 changes: 11 additions & 9 deletions packages/k8s-ui/src/components/ui/SelectMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export function SelectMenu({
if (!normalized) return options
return options.filter((option) => `${option.label} ${option.description ?? ''}`.toLowerCase().includes(normalized))
}, [options, query])
const selectedIsVisible = filteredOptions.some((option) => option.value === value)
// Options can be replaced while the menu is open, leaving the highlight past
// the end; without the clamp no option is tabbable and Tab skips the list.
const activeIndex = Math.min(highlightedIndex, filteredOptions.length - 1)

const focusTabbableOption = () => {
listRef.current?.querySelector<HTMLElement>('[role="option"][tabindex="0"]')?.focus()
Expand Down Expand Up @@ -153,7 +155,9 @@ export function SelectMenu({
ref={triggerRef}
id={id}
type="button"
aria-label={ariaLabel}
// The fixed label would otherwise hide the visible value from screen
// readers, which a native select announces.
aria-label={selected ? `${ariaLabel}: ${selected.label}` : ariaLabel}
aria-describedby={ariaDescribedBy}
aria-haspopup="listbox"
aria-expanded={open}
Expand Down Expand Up @@ -216,11 +220,11 @@ export function SelectMenu({
onKeyDown={(event) => {
if (event.key === 'Enter' && filteredOptions.length > 0) {
event.preventDefault()
selectOption(filteredOptions[Math.min(highlightedIndex, filteredOptions.length - 1)].value)
selectOption(filteredOptions[activeIndex].value)
} else if (event.key === 'ArrowDown') {
event.preventDefault()
const optionElements = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')
optionElements?.[Math.min(highlightedIndex, optionElements.length - 1)]?.focus()
optionElements?.[activeIndex]?.focus()
}
}}
aria-label={searchPlaceholder}
Expand All @@ -230,7 +234,7 @@ export function SelectMenu({
aria-expanded="true"
aria-activedescendant={
filteredOptions.length > 0
? `${listboxId}-option-${Math.min(highlightedIndex, filteredOptions.length - 1)}`
? `${listboxId}-option-${activeIndex}`
: undefined
}
placeholder={searchPlaceholder}
Expand Down Expand Up @@ -283,15 +287,13 @@ export function SelectMenu({
role="option"
aria-selected={active}
aria-disabled={option.disabled || undefined}
tabIndex={
searchPlaceholder ? (index === highlightedIndex ? 0 : -1) : active || (!selectedIsVisible && index === 0) ? 0 : -1
}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => selectOption(option.value)}
onFocus={() => setHighlightedIndex(index)}
className={clsx(
'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors',
option.disabled ? 'cursor-not-allowed opacity-60' : 'hover:bg-theme-hover hover:text-theme-text-primary',
searchPlaceholder && index === highlightedIndex && 'bg-theme-hover text-theme-text-primary',
searchPlaceholder && index === activeIndex && 'bg-theme-hover text-theme-text-primary',
!searchPlaceholder && 'whitespace-nowrap'
)}
>
Expand Down
Loading
Loading