@skyhook-io/k8s-ui 1.13.4 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/components/resources/ResourcesView.tsx +2 -2
- package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
- package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
- package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
- package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
- package/src/components/resources/resource-utils-cnpg.ts +132 -7
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +14 -0
- package/src/components/resources/resource-utils-kueue.ts +4 -2
- package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
- package/src/components/resources/resource-utils-kyverno.ts +51 -2
- package/src/components/resources/resource-utils-velero.ts +32 -0
- package/src/components/ui/SelectMenu.tsx +10 -1
|
@@ -145,6 +145,100 @@ export function classifyCNPGClusterPhase(phase: string): CNPGPhaseBucket {
|
|
|
145
145
|
return 'unknown'
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// Debounce window for calling a was-up cluster "down". A single instance
|
|
149
|
+
// restarting flips Ready=False for a minute or two while the pod comes back;
|
|
150
|
+
// escalating instantly would fire on routine rollouts. Kept in step with
|
|
151
|
+
// cnpgDownGrace in internal/issues/source_cnpg.go — the two must not drift.
|
|
152
|
+
export const CNPG_DOWN_GRACE_MS = 5 * 60 * 1000
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Hibernation is a deliberate scale-to-zero: the operator removes the pods and
|
|
156
|
+
* the cluster reports nothing ready by design. Signalled by an annotation, not
|
|
157
|
+
* by any status field, so it has to be read before a zero-ready count is called
|
|
158
|
+
* an outage.
|
|
159
|
+
*/
|
|
160
|
+
export function isCNPGClusterHibernated(resource: any): boolean {
|
|
161
|
+
return resource?.metadata?.annotations?.['cnpg.io/hibernation'] === 'on'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fencing stops PostgreSQL on the named instances while leaving the pods up. The
|
|
166
|
+
* annotation is a JSON array of instance names; `"*"` means every instance is
|
|
167
|
+
* fenced, so nothing serves — intentionally, not as a fault. A malformed value
|
|
168
|
+
* is treated as "not fenced" rather than throwing.
|
|
169
|
+
*/
|
|
170
|
+
export function isCNPGClusterFencedAll(resource: any): boolean {
|
|
171
|
+
const raw = resource?.metadata?.annotations?.['cnpg.io/fencedInstances']
|
|
172
|
+
if (typeof raw !== 'string' || raw === '') return false
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(raw)
|
|
175
|
+
return Array.isArray(parsed) && parsed.includes('*')
|
|
176
|
+
} catch {
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether a zero-ready cluster is actually unavailable, and why.
|
|
183
|
+
*
|
|
184
|
+
* The load-bearing distinction the badge and the Go detector both turn on:
|
|
185
|
+
* CNPG omits `status.readyInstances` when it is 0 (omitempty int), so "no
|
|
186
|
+
* status yet" and "0 ready" are byte-identical on the wire. The field's
|
|
187
|
+
* presence therefore cannot distinguish an outage from an unreported cluster;
|
|
188
|
+
* any other status the operator has written (phase, primary, a condition) is
|
|
189
|
+
* what marks the count as a real 0 rather than "not reported".
|
|
190
|
+
*
|
|
191
|
+
* `status.currentPrimary` is the version-robust "was up" signal: CNPG sets it
|
|
192
|
+
* the first time a primary is elected and never clears it (present on 1.27 and
|
|
193
|
+
* 1.28). So a zero-ready cluster that HAS a currentPrimary was serving before —
|
|
194
|
+
* a regression — while one that never had a primary is still bootstrapping.
|
|
195
|
+
*
|
|
196
|
+
* - null — not down: still bootstrapping, or a real ready count exists
|
|
197
|
+
* - 'hibernated' / 'fenced' — deliberately not serving, no alarm
|
|
198
|
+
* - 'down' — was up, now zero ready past the grace window
|
|
199
|
+
*
|
|
200
|
+
* Mirrored by getCNPGClusterAvailability's Go counterpart in
|
|
201
|
+
* internal/issues/source_cnpg.go (the allDown verdict) — same signals, same
|
|
202
|
+
* grace, or a badge and its issue disagree.
|
|
203
|
+
*/
|
|
204
|
+
export function getCNPGClusterAvailability(
|
|
205
|
+
resource: any,
|
|
206
|
+
): 'hibernated' | 'fenced' | 'down' | null {
|
|
207
|
+
const status = resource.status || {}
|
|
208
|
+
const desired = resource.spec?.instances ?? 0
|
|
209
|
+
const ready = typeof status.readyInstances === 'number' ? status.readyInstances : 0
|
|
210
|
+
// Any status the operator has written proves it has reconciled at least once,
|
|
211
|
+
// which is what lets an absent readyInstances be read as a real 0 rather than
|
|
212
|
+
// "not reported". Absent EVERYTHING is the truly-statusless case, still unknown.
|
|
213
|
+
const reported = !!(status.phase || status.currentPrimary || status.conditions?.length)
|
|
214
|
+
if (desired <= 0 || ready > 0 || !reported) return null
|
|
215
|
+
// Deliberate not-serving states outrank the down verdict — checked before the
|
|
216
|
+
// was-up gate so a hibernated cluster reads neutral even if it kept a primary.
|
|
217
|
+
if (isCNPGClusterHibernated(resource)) return 'hibernated'
|
|
218
|
+
if (isCNPGClusterFencedAll(resource)) return 'fenced'
|
|
219
|
+
// Never elected a primary → first bootstrap, not a regression.
|
|
220
|
+
if (!status.currentPrimary) return null
|
|
221
|
+
const conditions = status.conditions || []
|
|
222
|
+
const readyCond = conditions.find((c: any) => c.type === 'Ready')
|
|
223
|
+
// A was-up cluster whose Ready only just went False is likely a single
|
|
224
|
+
// instance bouncing; wait out the grace. No Ready=False at all (or no
|
|
225
|
+
// timestamp) means nothing is debouncing, so escalate immediately rather than
|
|
226
|
+
// inventing a start time.
|
|
227
|
+
if (
|
|
228
|
+
readyCond?.status === 'False' &&
|
|
229
|
+
readyCond.lastTransitionTime &&
|
|
230
|
+
Date.now() - Date.parse(readyCond.lastTransitionTime) < CNPG_DOWN_GRACE_MS
|
|
231
|
+
) {
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
// A cluster woken from hibernation or lifted from a full fence transiently
|
|
235
|
+
// shows this same shape — was-up, zero ready, Ready=False from long before —
|
|
236
|
+
// until its first instance is Ready. CNPG drops the hibernation marker at that
|
|
237
|
+
// point, so from the Cluster status alone this is indistinguishable from a real
|
|
238
|
+
// outage, and it reads down for that short window.
|
|
239
|
+
return 'down'
|
|
240
|
+
}
|
|
241
|
+
|
|
148
242
|
export function getCNPGClusterStatus(resource: any): StatusBadge {
|
|
149
243
|
const status = resource.status || {}
|
|
150
244
|
const phase = status.phase || ''
|
|
@@ -165,10 +259,19 @@ export function getCNPGClusterStatus(resource: any): StatusBadge {
|
|
|
165
259
|
if (bucket === 'terminal') {
|
|
166
260
|
return { text: getCNPGClusterDisplayState(phase), color: healthColors.unhealthy, level: 'unhealthy' }
|
|
167
261
|
}
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
262
|
+
// Availability outranks every phase branch except terminal. A zero-ready
|
|
263
|
+
// cluster CNPG reports by omitting readyInstances would otherwise be invisible
|
|
264
|
+
// here (countsKnown is false without the field) and fall through to the
|
|
265
|
+
// transient phase, painting amber on a total outage. Hibernation and all-fenced
|
|
266
|
+
// are deliberate not-serving states, so they read neutral rather than red.
|
|
267
|
+
const avail = getCNPGClusterAvailability(resource)
|
|
268
|
+
if (avail === 'hibernated') {
|
|
269
|
+
return { text: 'Hibernated', color: healthColors.neutral, level: 'neutral' }
|
|
270
|
+
}
|
|
271
|
+
if (avail === 'fenced') {
|
|
272
|
+
return { text: 'Fenced', color: healthColors.neutral, level: 'neutral' }
|
|
273
|
+
}
|
|
274
|
+
if (avail === 'down') {
|
|
172
275
|
return { text: 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
173
276
|
}
|
|
174
277
|
if (bucket === 'failing') {
|
|
@@ -241,7 +344,20 @@ export function getCNPGClusterStatus(resource: any): StatusBadge {
|
|
|
241
344
|
const countOrDash = (n: unknown): string => (typeof n === 'number' ? String(n) : '-')
|
|
242
345
|
|
|
243
346
|
export function getCNPGClusterInstances(resource: any): string {
|
|
244
|
-
|
|
347
|
+
const status = resource.status || {}
|
|
348
|
+
// Absent readyInstances resolves to 0 only for a was-up cluster (one that
|
|
349
|
+
// elected a primary): there the omitted field genuinely means zero ready, and
|
|
350
|
+
// "0/3" agrees with the "Not Ready" badge. Before a primary exists the count
|
|
351
|
+
// is truly unknown, so it stays a dash — the guard against fabricating an
|
|
352
|
+
// outage the badge denies. currentPrimary is the same was-up signal the badge
|
|
353
|
+
// gates its down verdict on, so the cell can never read 0 beside a green badge.
|
|
354
|
+
const ready =
|
|
355
|
+
typeof status.readyInstances === 'number'
|
|
356
|
+
? status.readyInstances
|
|
357
|
+
: status.currentPrimary
|
|
358
|
+
? 0
|
|
359
|
+
: undefined
|
|
360
|
+
return `${countOrDash(ready)}/${countOrDash(resource.spec?.instances)}`
|
|
245
361
|
}
|
|
246
362
|
|
|
247
363
|
export function getCNPGClusterPrimary(resource: any): string {
|
|
@@ -554,12 +670,21 @@ export function getCNPGClusterMonitoring(resource: any): {
|
|
|
554
670
|
}
|
|
555
671
|
}
|
|
556
672
|
|
|
673
|
+
// CNPG carries replica-cluster state in `spec.replica`. Presence is not the
|
|
674
|
+
// signal: promoting a replica to primary mutates the stanza's fields but leaves
|
|
675
|
+
// it in place. This mirrors the operator's own Cluster.IsReplica() — `enabled`
|
|
676
|
+
// decides when set; otherwise the cluster is a replica only while it is not the
|
|
677
|
+
// designated primary of the distributed topology ((self || name) != primary).
|
|
557
678
|
export function getCNPGClusterIsReplica(resource: any): boolean {
|
|
558
|
-
|
|
679
|
+
const replica = resource.spec?.replica
|
|
680
|
+
if (!replica) return false
|
|
681
|
+
if (replica.enabled != null) return replica.enabled === true
|
|
682
|
+
const self = replica.self || resource.metadata?.name
|
|
683
|
+
return self !== replica.primary
|
|
559
684
|
}
|
|
560
685
|
|
|
561
686
|
export function getCNPGClusterReplicaSource(resource: any): string {
|
|
562
|
-
const replica = resource.spec?.
|
|
687
|
+
const replica = resource.spec?.replica
|
|
563
688
|
if (!replica) return '-'
|
|
564
689
|
return replica.source || replica.primary || '-'
|
|
565
690
|
}
|
|
@@ -37,6 +37,20 @@ describe('GPU ecosystem API contracts', () => {
|
|
|
37
37
|
expect(getKueueWorkloadStatus(workload).text).toBe('Evicted')
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
it('distinguishes Kueue terminal failures from successful workloads', () => {
|
|
41
|
+
for (const reason of ['Failed', 'FailedToStart', 'OutOfSync', 'OwnerNotFound']) {
|
|
42
|
+
expect(getKueueWorkloadStatus({
|
|
43
|
+
status: { conditions: [{ type: 'Finished', status: 'True', reason }] },
|
|
44
|
+
})).toMatchObject({ text: reason, level: 'unhealthy' })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const reason of ['Succeeded', 'WorkloadSliceReplaced']) {
|
|
48
|
+
expect(getKueueWorkloadStatus({
|
|
49
|
+
status: { conditions: [{ type: 'Finished', status: 'True', reason }] },
|
|
50
|
+
})).toMatchObject({ text: 'Finished', level: 'neutral' })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
40
54
|
it('lets KubeRay conditions outrank deprecated state fields', () => {
|
|
41
55
|
expect(getRayClusterStatus({
|
|
42
56
|
status: {
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import type { StatusBadge } from './resource-utils'
|
|
4
4
|
import { healthColors } from './resource-utils'
|
|
5
5
|
|
|
6
|
+
const failedFinishedReasons = new Set(['Failed', 'FailedToStart', 'OutOfSync', 'OwnerNotFound'])
|
|
7
|
+
|
|
6
8
|
// ============================================================================
|
|
7
9
|
// SHARED HELPERS
|
|
8
10
|
// ============================================================================
|
|
@@ -84,8 +86,8 @@ export function getLocalQueueAdmittedWorkloads(resource: any): string {
|
|
|
84
86
|
export function getKueueWorkloadStatus(resource: any): StatusBadge {
|
|
85
87
|
const finished = findCondition(resource, 'Finished')
|
|
86
88
|
if (finished?.status === 'True') {
|
|
87
|
-
if (finished.reason
|
|
88
|
-
return { text:
|
|
89
|
+
if (failedFinishedReasons.has(finished.reason)) {
|
|
90
|
+
return { text: finished.reason, color: healthColors.unhealthy, level: 'unhealthy' }
|
|
89
91
|
}
|
|
90
92
|
return { text: 'Finished', color: healthColors.neutral, level: 'neutral' }
|
|
91
93
|
}
|
|
@@ -472,7 +472,9 @@ export function getKyvernoMutations(resource: any): KyvernoMutation[] {
|
|
|
472
472
|
export function getKyvernoGenerations(resource: any): string[] {
|
|
473
473
|
const generate = resource?.spec?.generate
|
|
474
474
|
if (!Array.isArray(generate)) return []
|
|
475
|
-
|
|
475
|
+
// Each entry declares its target as exactly one of a CEL `expression` or a
|
|
476
|
+
// YAML `template.value`; both are first-class ways to say what gets generated.
|
|
477
|
+
return generate.map((g: any) => g?.expression || g?.template?.value || '').filter(Boolean)
|
|
476
478
|
}
|
|
477
479
|
|
|
478
480
|
/**
|
|
@@ -115,6 +115,16 @@ export function getKyvernoPolicyStatus(resource: any): StatusBadge {
|
|
|
115
115
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Kyverno's ValidationFailureAction enum accepts the deprecated lowercase
|
|
120
|
+
* `enforce` alongside `Enforce`, and the admission controller blocks on the
|
|
121
|
+
* lowercase spelling exactly as on the capitalized one. Match case-insensitively
|
|
122
|
+
* so a legacy policy carrying the lowercase value is not read as non-blocking.
|
|
123
|
+
*/
|
|
124
|
+
export function isKyvernoEnforceAction(action: unknown): boolean {
|
|
125
|
+
return String(action ?? '').toLowerCase() === 'enforce'
|
|
126
|
+
}
|
|
127
|
+
|
|
118
128
|
/** The rule blocks whose failure an admission request can be rejected for. */
|
|
119
129
|
function rejectingActions(rule: any): string[] {
|
|
120
130
|
if (!rule) return []
|
|
@@ -148,14 +158,14 @@ function rejectingActions(rule: any): string[] {
|
|
|
148
158
|
export function getKyvernoPolicyAction(resource: any): string {
|
|
149
159
|
const specAction = resource.spec?.validationFailureAction
|
|
150
160
|
const rules = resource.spec?.rules || []
|
|
151
|
-
if (rules.length === 0) return specAction
|
|
161
|
+
if (rules.length === 0) return isKyvernoEnforceAction(specAction) ? 'Enforce' : 'Audit'
|
|
152
162
|
const rejecting = rules.filter((rule: any) => rule?.validate || rule?.verifyImages)
|
|
153
163
|
// Rules, but none that can reject: the spec-level action governs nothing here.
|
|
154
164
|
if (rejecting.length === 0) return 'Audit'
|
|
155
165
|
for (const rule of rejecting) {
|
|
156
166
|
const overrides = rejectingActions(rule)
|
|
157
167
|
const effective = overrides.length > 0 ? overrides : [specAction]
|
|
158
|
-
if (effective.
|
|
168
|
+
if (effective.some(isKyvernoEnforceAction)) return 'Enforce'
|
|
159
169
|
}
|
|
160
170
|
return 'Audit'
|
|
161
171
|
}
|
|
@@ -204,6 +214,45 @@ export function getKyvernoPolicyBackground(resource: any): boolean {
|
|
|
204
214
|
return resource.spec?.background !== false
|
|
205
215
|
}
|
|
206
216
|
|
|
217
|
+
/**
|
|
218
|
+
* `spec.admission`, defaulting to true — the same reading as Kyverno's own
|
|
219
|
+
* AdmissionProcessingEnabled(). When false the policy is never registered with
|
|
220
|
+
* the admission webhook, so it rejects nothing at admission whatever its
|
|
221
|
+
* failureAction says; it can only be seen by background scans.
|
|
222
|
+
*/
|
|
223
|
+
export function getKyvernoPolicyAdmission(resource: any): boolean {
|
|
224
|
+
return resource.spec?.admission !== false
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface KyvernoEnforcement {
|
|
228
|
+
/** Badge label. */
|
|
229
|
+
label: string
|
|
230
|
+
/** True only when a violating request is actually rejected at admission. */
|
|
231
|
+
blocks: boolean
|
|
232
|
+
/** Declared to reject, but admission is off — it enforces nothing at admission. */
|
|
233
|
+
discrepancy: boolean
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* What a legacy policy does at admission, not just what its failureAction field
|
|
238
|
+
* reads. An Enforce policy with admission disabled blocks nothing — presenting
|
|
239
|
+
* it as a blocking "Enforce" would tell an operator the cluster is guarded when
|
|
240
|
+
* it is not. Labels match the modern CEL family's vocabulary for the same
|
|
241
|
+
* posture: "Background only" when background scans still report violations,
|
|
242
|
+
* "Inactive" when background is disabled too and the policy does nothing.
|
|
243
|
+
*/
|
|
244
|
+
export function getKyvernoEnforcement(resource: any): KyvernoEnforcement {
|
|
245
|
+
const action = getKyvernoPolicyAction(resource)
|
|
246
|
+
if (action !== 'Enforce' || getKyvernoPolicyAdmission(resource)) {
|
|
247
|
+
return { label: action, blocks: action === 'Enforce', discrepancy: false }
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
label: getKyvernoPolicyBackground(resource) ? 'Background only' : 'Inactive',
|
|
251
|
+
blocks: false,
|
|
252
|
+
discrepancy: true,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
207
256
|
export function getKyvernoPolicyRuleCountByType(resource: any): {
|
|
208
257
|
validate: number
|
|
209
258
|
mutate: number
|
|
@@ -306,6 +306,30 @@ export function getRestoreExcludedResources(resource: any): string[] {
|
|
|
306
306
|
return resource.spec?.excludedResources || []
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
export function getRestoreNamespaceMapping(resource: any): Record<string, string> {
|
|
310
|
+
return resource.spec?.namespaceMapping || {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function getRestoreLabelSelector(resource: any): any {
|
|
314
|
+
return resource.spec?.labelSelector
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function getRestoreOrLabelSelectors(resource: any): any[] {
|
|
318
|
+
return resource.spec?.orLabelSelectors || []
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A label selector only narrows a restore when it carries at least one term. An
|
|
323
|
+
* empty selector matches everything, so it must not be presented as scope: doing
|
|
324
|
+
* so would tell an operator a restore is filtered when it is not.
|
|
325
|
+
*/
|
|
326
|
+
export function hasLabelSelectorTerms(selector: any): boolean {
|
|
327
|
+
if (!selector || typeof selector !== 'object') return false
|
|
328
|
+
const hasLabels = selector.matchLabels && Object.keys(selector.matchLabels).length > 0
|
|
329
|
+
const hasExpressions = Array.isArray(selector.matchExpressions) && selector.matchExpressions.length > 0
|
|
330
|
+
return Boolean(hasLabels || hasExpressions)
|
|
331
|
+
}
|
|
332
|
+
|
|
309
333
|
export function getRestoreDuration(resource: any): string {
|
|
310
334
|
const start = resource.status?.startTimestamp
|
|
311
335
|
const end = resource.status?.completionTimestamp
|
|
@@ -486,6 +510,14 @@ export function getBSLLastSynced(resource: any): string {
|
|
|
486
510
|
return formatAge(lastSynced)
|
|
487
511
|
}
|
|
488
512
|
|
|
513
|
+
// Why the location is unavailable, in the validation controller's own words.
|
|
514
|
+
// A BackupStorageLocation carries no conditions, so status.message is the only
|
|
515
|
+
// place the reason exists — without it the phase is the whole story a reader
|
|
516
|
+
// gets.
|
|
517
|
+
export function getBSLMessage(resource: any): string {
|
|
518
|
+
return resource.status?.message || ''
|
|
519
|
+
}
|
|
520
|
+
|
|
489
521
|
// ============================================================================
|
|
490
522
|
// VOLUME SNAPSHOT LOCATION UTILITIES
|
|
491
523
|
// ============================================================================
|
|
@@ -12,15 +12,21 @@ export function SelectMenu({
|
|
|
12
12
|
options,
|
|
13
13
|
onChange,
|
|
14
14
|
ariaLabel,
|
|
15
|
+
ariaDescribedBy,
|
|
15
16
|
className,
|
|
16
17
|
searchPlaceholder,
|
|
18
|
+
disabled = false,
|
|
19
|
+
id,
|
|
17
20
|
}: {
|
|
18
21
|
value: string
|
|
19
22
|
options: SelectMenuOption[]
|
|
20
23
|
onChange: (value: string) => void
|
|
21
24
|
ariaLabel: string
|
|
25
|
+
ariaDescribedBy?: string
|
|
22
26
|
className?: string
|
|
23
27
|
searchPlaceholder?: string
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
id?: string
|
|
24
30
|
}) {
|
|
25
31
|
const [open, setOpen] = useState(false)
|
|
26
32
|
const [query, setQuery] = useState('')
|
|
@@ -109,8 +115,10 @@ export function SelectMenu({
|
|
|
109
115
|
>
|
|
110
116
|
<button
|
|
111
117
|
ref={triggerRef}
|
|
118
|
+
id={id}
|
|
112
119
|
type="button"
|
|
113
120
|
aria-label={ariaLabel}
|
|
121
|
+
aria-describedby={ariaDescribedBy}
|
|
114
122
|
aria-haspopup="listbox"
|
|
115
123
|
aria-expanded={open}
|
|
116
124
|
aria-controls={open ? listboxId : undefined}
|
|
@@ -125,7 +133,8 @@ export function SelectMenu({
|
|
|
125
133
|
}
|
|
126
134
|
setOpen(!open)
|
|
127
135
|
}}
|
|
128
|
-
|
|
136
|
+
disabled={disabled}
|
|
137
|
+
className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-theme-elevated"
|
|
129
138
|
>
|
|
130
139
|
<span className="truncate">{selected?.label}</span>
|
|
131
140
|
<ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
|