@skyhook-io/k8s-ui 1.7.15 → 1.8.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 +2 -1
- package/src/components/applications/ApplicationsList.tsx +131 -115
- package/src/components/applications/index.ts +3 -1
- package/src/components/audit/AuditCard.tsx +1 -1
- package/src/components/charts/AreaChart.tsx +52 -18
- package/src/components/charts/MetricsSummary.tsx +12 -3
- package/src/components/charts/saturation.test.ts +34 -0
- package/src/components/charts/saturation.ts +1 -0
- package/src/components/charts/types.ts +5 -4
- package/src/components/checks/ChecksView.tsx +12 -23
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +298 -274
- package/src/components/issues/severity.ts +19 -11
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +13 -0
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
- package/src/components/resources/renderers/HPARenderer.tsx +93 -86
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
- package/src/components/resources/resource-utils-hpa.test.ts +30 -0
- package/src/components/resources/resource-utils-hpa.ts +106 -0
- package/src/components/resources/resource-utils.ts +2 -10
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/ui/DistributionBar.tsx +88 -0
- package/src/components/ui/Facet.tsx +130 -0
- package/src/components/ui/PageHeader.tsx +43 -0
- package/src/components/ui/SearchPillInput.tsx +237 -0
- package/src/components/ui/SortableTh.tsx +58 -0
- package/src/components/ui/SummaryTile.tsx +60 -0
- package/src/components/ui/drawer-components.tsx +56 -17
- package/src/components/ui/index.ts +12 -0
- package/src/components/workload/WorkloadView.tsx +18 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +7 -3
- package/src/types/core.ts +54 -2
- package/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { getHPATableState, hpaStateLabel } from './resource-utils-hpa'
|
|
5
|
+
import type { HPADiagnosisState } from '../../types'
|
|
6
|
+
|
|
7
|
+
interface FixtureCase {
|
|
8
|
+
name: string
|
|
9
|
+
hpa: unknown
|
|
10
|
+
expectedTableState: HPADiagnosisState
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('getHPATableState', () => {
|
|
14
|
+
for (const tc of loadCases()) {
|
|
15
|
+
it(`classifies ${tc.name}`, () => {
|
|
16
|
+
expect(getHPATableState(tc.hpa)).toBe(tc.expectedTableState)
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('hpaStateLabel', () => {
|
|
22
|
+
it('keeps max-bound wording terse for table cells', () => {
|
|
23
|
+
expect(hpaStateLabel('limited_max')).toBe('Maxed')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function loadCases(): FixtureCase[] {
|
|
28
|
+
const path = resolve(process.cwd(), '../../testdata/hpa-diagnosis/cases.json')
|
|
29
|
+
return JSON.parse(readFileSync(path, 'utf8')) as FixtureCase[]
|
|
30
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { HealthLevel, StatusBadge } from './resource-utils'
|
|
2
|
+
import type { HPADiagnosisState } from '../../types'
|
|
3
|
+
|
|
4
|
+
export function getHPATableState(hpa: any): HPADiagnosisState {
|
|
5
|
+
// List rows only classify states worth scanning in a table; the Go diagnosis powers the full drawer context.
|
|
6
|
+
const conditions = Array.isArray(hpa?.status?.conditions) ? hpa.status.conditions : []
|
|
7
|
+
const current = hpa?.status?.currentReplicas ?? 0
|
|
8
|
+
const desired = hpa?.status?.desiredReplicas ?? 0
|
|
9
|
+
const min = hpa?.spec?.minReplicas ?? 1
|
|
10
|
+
const max = hpa?.spec?.maxReplicas ?? 0
|
|
11
|
+
|
|
12
|
+
if (conditionStatus(conditions, 'AbleToScale') === 'False') return 'unable_to_scale'
|
|
13
|
+
const scalingActive = condition(conditions, 'ScalingActive')
|
|
14
|
+
if (scalingActive?.status === 'False') {
|
|
15
|
+
if (isScalingDisabled(scalingActive)) return 'disabled'
|
|
16
|
+
return 'metrics_unavailable'
|
|
17
|
+
}
|
|
18
|
+
if (max > 0 && min === max && current === desired && desired === max) return 'pinned'
|
|
19
|
+
if (isMaxLimited(condition(conditions, 'ScalingLimited'))) return 'limited_max'
|
|
20
|
+
|
|
21
|
+
if (current < desired) return 'scaling_up'
|
|
22
|
+
if (current > desired) return 'scaling_down'
|
|
23
|
+
return 'ok'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hpaStateLabel(state: HPADiagnosisState): string {
|
|
27
|
+
switch (state) {
|
|
28
|
+
case 'unable_to_scale':
|
|
29
|
+
return 'Unable to scale'
|
|
30
|
+
case 'metrics_unavailable':
|
|
31
|
+
return 'Metrics unavailable'
|
|
32
|
+
case 'metrics_incomplete':
|
|
33
|
+
return 'Metrics incomplete'
|
|
34
|
+
case 'disabled':
|
|
35
|
+
return 'Disabled'
|
|
36
|
+
case 'pinned':
|
|
37
|
+
return 'Pinned'
|
|
38
|
+
case 'limited_max':
|
|
39
|
+
return 'Maxed'
|
|
40
|
+
case 'limited_min':
|
|
41
|
+
return 'At min'
|
|
42
|
+
case 'stale':
|
|
43
|
+
return 'Stale'
|
|
44
|
+
case 'stabilized':
|
|
45
|
+
return 'Stabilized'
|
|
46
|
+
case 'scaling_up':
|
|
47
|
+
return 'Scaling Up'
|
|
48
|
+
case 'scaling_down':
|
|
49
|
+
return 'Scaling Down'
|
|
50
|
+
case 'ok':
|
|
51
|
+
return 'Stable'
|
|
52
|
+
default:
|
|
53
|
+
return 'Unknown'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hpaStateLevel(state: HPADiagnosisState): HealthLevel {
|
|
58
|
+
switch (state) {
|
|
59
|
+
case 'unable_to_scale':
|
|
60
|
+
case 'metrics_unavailable':
|
|
61
|
+
return 'unhealthy'
|
|
62
|
+
case 'limited_max':
|
|
63
|
+
case 'metrics_incomplete':
|
|
64
|
+
return 'degraded'
|
|
65
|
+
case 'scaling_up':
|
|
66
|
+
case 'scaling_down':
|
|
67
|
+
return 'degraded'
|
|
68
|
+
case 'limited_min':
|
|
69
|
+
case 'disabled':
|
|
70
|
+
case 'pinned':
|
|
71
|
+
case 'stabilized':
|
|
72
|
+
return 'neutral'
|
|
73
|
+
case 'stale':
|
|
74
|
+
return 'unknown'
|
|
75
|
+
case 'ok':
|
|
76
|
+
return 'healthy'
|
|
77
|
+
default:
|
|
78
|
+
return 'unknown'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function hpaStatusFromState(state: HPADiagnosisState, healthColors: Record<HealthLevel, string>): StatusBadge {
|
|
83
|
+
const level = hpaStateLevel(state)
|
|
84
|
+
return { text: hpaStateLabel(state), color: healthColors[level], level }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function conditionStatus(conditions: any[], type: string): string | undefined {
|
|
88
|
+
return condition(conditions, type)?.status
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function condition(conditions: any[], type: string): any | undefined {
|
|
92
|
+
return conditions.find((item) => item?.type === type)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isScalingDisabled(item: any): boolean {
|
|
96
|
+
const reason = String(item?.reason ?? '').toLowerCase()
|
|
97
|
+
const message = String(item?.message ?? '').toLowerCase()
|
|
98
|
+
return reason === 'scalingdisabled' || message.includes('scaling is disabled')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isMaxLimited(item: any): boolean {
|
|
102
|
+
if (item?.status !== 'True') return false
|
|
103
|
+
const reason = String(item?.reason ?? '').toLowerCase()
|
|
104
|
+
const message = String(item?.message ?? '').toLowerCase()
|
|
105
|
+
return reason.includes('toomany') || message.includes('maximum')
|
|
106
|
+
}
|
|
@@ -14,6 +14,7 @@ import { getResourceClaimStatus as _getResourceClaimStatus, getResourceClaimDevi
|
|
|
14
14
|
import { getNvidiaClusterPolicyStatus as _getNvidiaClusterPolicyStatus, getNvidiaClusterPolicyEnabledComponents as _getNvidiaClusterPolicyEnabledComponents, getNvidiaDriverStatus as _getNvidiaDriverStatus } from './resource-utils-nvidia'
|
|
15
15
|
import { getBackupStatus as _getBackupStatus, getRestoreStatus as _getRestoreStatus, getScheduleStatus as _getScheduleStatus, getBSLStatus as _getBSLStatus } from './resource-utils-velero'
|
|
16
16
|
import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternalSecretStatus as _getClusterExternalSecretStatus, getSecretStoreStatus as _getSecretStoreStatus, getClusterSecretStoreStatus as _getClusterSecretStoreStatus, getSecretStoreProviderType as _getSecretStoreProviderType } from './resource-utils-eso'
|
|
17
|
+
import { getHPATableState, hpaStatusFromState } from './resource-utils-hpa'
|
|
17
18
|
|
|
18
19
|
// ============================================================================
|
|
19
20
|
// STATUS & HEALTH UTILITIES
|
|
@@ -960,16 +961,7 @@ export function getCronJobLastRun(cj: any): string | null {
|
|
|
960
961
|
// ============================================================================
|
|
961
962
|
|
|
962
963
|
export function getHPAStatus(hpa: any): StatusBadge {
|
|
963
|
-
|
|
964
|
-
const desired = hpa.status?.desiredReplicas || 0
|
|
965
|
-
|
|
966
|
-
if (current === desired) {
|
|
967
|
-
return { text: 'Stable', color: healthColors.healthy, level: 'healthy' }
|
|
968
|
-
}
|
|
969
|
-
if (current < desired) {
|
|
970
|
-
return { text: 'Scaling Up', color: healthColors.degraded, level: 'degraded' }
|
|
971
|
-
}
|
|
972
|
-
return { text: 'Scaling Down', color: healthColors.degraded, level: 'degraded' }
|
|
964
|
+
return hpaStatusFromState(getHPATableState(hpa), healthColors)
|
|
973
965
|
}
|
|
974
966
|
|
|
975
967
|
export function getHPAReplicas(hpa: any): { current: number; min: number; max: number } {
|
|
@@ -226,10 +226,11 @@ import {
|
|
|
226
226
|
isComposite,
|
|
227
227
|
isClaim,
|
|
228
228
|
} from '../resources/resource-utils-crossplane'
|
|
229
|
-
import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent } from '../../types'
|
|
229
|
+
import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent, HPADiagnosis } from '../../types'
|
|
230
230
|
import type { CopyHandler } from '../ui/drawer-components'
|
|
231
231
|
import { AlertBanner } from '../ui/drawer-components'
|
|
232
232
|
import { replicaScalers } from '../../utils/replica-scalers'
|
|
233
|
+
import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
|
|
233
234
|
|
|
234
235
|
/**
|
|
235
236
|
* Override map letting each platform consumer swap in its own renderer components.
|
|
@@ -258,6 +259,7 @@ export interface RendererOverrides {
|
|
|
258
259
|
onNavigate?: (ref: ResourceRef) => void
|
|
259
260
|
relationships?: Relationships
|
|
260
261
|
scaleBlockedBy?: ResourceRef[]
|
|
262
|
+
scalerDiagnostics?: ScalerDiagnosis[]
|
|
261
263
|
}>
|
|
262
264
|
// Optional override for Crossplane Composite / Claim — host wraps the
|
|
263
265
|
// package renderer to fan out per-composed-ref status fetches via React Query.
|
|
@@ -297,6 +299,7 @@ export interface RendererOverrides {
|
|
|
297
299
|
HPARenderer?: React.ComponentType<{
|
|
298
300
|
data: any
|
|
299
301
|
onNavigate?: (ref: ResourceRef) => void
|
|
302
|
+
hpaDiagnosis?: HPADiagnosis
|
|
300
303
|
}>
|
|
301
304
|
// PVC: host wraps the base renderer to add a kubelet-derived usage gauge
|
|
302
305
|
// when Prometheus is scraping kubelet endpoints.
|
|
@@ -376,6 +379,8 @@ interface ResourceRendererDispatchProps {
|
|
|
376
379
|
data: any
|
|
377
380
|
relationships?: Relationships
|
|
378
381
|
certificateInfo?: SecretCertificateInfo
|
|
382
|
+
hpaDiagnosis?: HPADiagnosis
|
|
383
|
+
scalerDiagnostics?: ScalerDiagnosis[]
|
|
379
384
|
onCopy: (text: string, key: string) => void
|
|
380
385
|
copied: string | null
|
|
381
386
|
onNavigate?: (ref: ResourceRef) => void
|
|
@@ -417,6 +422,8 @@ export function ResourceRendererDispatch({
|
|
|
417
422
|
data,
|
|
418
423
|
relationships,
|
|
419
424
|
certificateInfo,
|
|
425
|
+
hpaDiagnosis,
|
|
426
|
+
scalerDiagnostics,
|
|
420
427
|
onCopy,
|
|
421
428
|
copied,
|
|
422
429
|
onNavigate,
|
|
@@ -503,6 +510,7 @@ export function ResourceRendererDispatch({
|
|
|
503
510
|
onNavigate={onNavigate}
|
|
504
511
|
relationships={relationships}
|
|
505
512
|
scaleBlockedBy={scaleBlockedBy}
|
|
513
|
+
scalerDiagnostics={scalerDiagnostics}
|
|
506
514
|
/>
|
|
507
515
|
)}
|
|
508
516
|
{kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
|
|
@@ -513,7 +521,7 @@ export function ResourceRendererDispatch({
|
|
|
513
521
|
{kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
|
|
514
522
|
{kind === 'jobs' && <JobRenderer data={data} />}
|
|
515
523
|
{kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
|
|
516
|
-
{(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} />}
|
|
524
|
+
{(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} hpaDiagnosis={hpaDiagnosis} />}
|
|
517
525
|
{kind === 'nodes' && <NodeComp data={data} relationships={relationships} />}
|
|
518
526
|
{kind === 'persistentvolumeclaims' && <PVCComp data={data} onNavigate={onNavigate} />}
|
|
519
527
|
{kind === 'rollouts' && <RolloutRenderer data={data} />}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
|
|
3
|
+
export interface DistributionSegment {
|
|
4
|
+
key: string
|
|
5
|
+
count: number
|
|
6
|
+
/** Tailwind background class for this segment's fill (e.g. status-tone bg). */
|
|
7
|
+
fillClass: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A single segmented proportion bar — the shared "distribution of statuses"
|
|
11
|
+
// element behind the Checks severity rollup, the Applications/GitOps health
|
|
12
|
+
// rollup, and the Issues rollup. One height, one track, one radius everywhere.
|
|
13
|
+
//
|
|
14
|
+
// Intentional empty state: with no data (sum === 0) it renders NOTHING rather
|
|
15
|
+
// than a flat grey track that reads as a skeleton/loading bar on sparse clusters.
|
|
16
|
+
export function DistributionBar({
|
|
17
|
+
segments,
|
|
18
|
+
className,
|
|
19
|
+
ariaLabel = 'Distribution',
|
|
20
|
+
}: {
|
|
21
|
+
segments: DistributionSegment[]
|
|
22
|
+
className?: string
|
|
23
|
+
ariaLabel?: string
|
|
24
|
+
}) {
|
|
25
|
+
const sum = segments.reduce((n, s) => n + s.count, 0)
|
|
26
|
+
if (sum === 0) return null
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className={clsx('flex h-1.5 overflow-hidden rounded-full bg-theme-elevated', className)}
|
|
30
|
+
role="img"
|
|
31
|
+
aria-label={ariaLabel}
|
|
32
|
+
>
|
|
33
|
+
{segments.map((s) =>
|
|
34
|
+
s.count > 0 ? (
|
|
35
|
+
<div
|
|
36
|
+
key={s.key}
|
|
37
|
+
className={clsx(s.fillClass, 'transition-[width] duration-500 ease-out')}
|
|
38
|
+
style={{ width: `${(s.count / sum) * 100}%` }}
|
|
39
|
+
/>
|
|
40
|
+
) : null,
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A legend entry for a DistributionBar: a tone dot + count + label. When onClick
|
|
47
|
+
// is provided it's a toggle (the legend doubles as the filter, as in the Checks
|
|
48
|
+
// and Issues queues); without it, it's a static legend item.
|
|
49
|
+
export function DistributionLegendChip({
|
|
50
|
+
label,
|
|
51
|
+
count,
|
|
52
|
+
fillClass,
|
|
53
|
+
textClass,
|
|
54
|
+
active = false,
|
|
55
|
+
onClick,
|
|
56
|
+
}: {
|
|
57
|
+
label: string
|
|
58
|
+
count: number
|
|
59
|
+
fillClass: string
|
|
60
|
+
/** Tone text class applied to the count when count > 0. */
|
|
61
|
+
textClass?: string
|
|
62
|
+
active?: boolean
|
|
63
|
+
onClick?: () => void
|
|
64
|
+
}) {
|
|
65
|
+
const inner = (
|
|
66
|
+
<>
|
|
67
|
+
<span className={clsx('h-2 w-2 rounded-full', fillClass, count === 0 && 'opacity-30')} />
|
|
68
|
+
<span className={clsx('font-semibold tabular-nums', count > 0 ? textClass : 'text-theme-text-tertiary')}>{count}</span>
|
|
69
|
+
<span>{label}</span>
|
|
70
|
+
</>
|
|
71
|
+
)
|
|
72
|
+
if (!onClick) {
|
|
73
|
+
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-theme-text-secondary">{inner}</span>
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={onClick}
|
|
79
|
+
aria-pressed={active}
|
|
80
|
+
className={clsx(
|
|
81
|
+
'group inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
|
82
|
+
active ? 'border-theme-border bg-theme-elevated text-theme-text-primary' : 'border-transparent text-theme-text-secondary hover:bg-theme-hover/60',
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{inner}
|
|
86
|
+
</button>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Info } from 'lucide-react'
|
|
4
|
+
import { Tooltip } from './Tooltip'
|
|
5
|
+
|
|
6
|
+
// Shared faceted-filter primitives for sidebar filter rails. One implementation
|
|
7
|
+
// behind the GitOps and Applications rails so the "this filter is on" language
|
|
8
|
+
// stays identical everywhere — active = the skyhook accent, the same signal the
|
|
9
|
+
// nav rail and header status tiles use.
|
|
10
|
+
|
|
11
|
+
export type FacetTone = 'neutral' | 'success' | 'warning' | 'error' | 'info'
|
|
12
|
+
|
|
13
|
+
const DOT_CLASS: Record<FacetTone, string> = {
|
|
14
|
+
neutral: 'bg-theme-text-tertiary',
|
|
15
|
+
success: 'bg-emerald-500',
|
|
16
|
+
warning: 'bg-amber-500',
|
|
17
|
+
error: 'bg-red-500',
|
|
18
|
+
info: 'bg-sky-500',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A titled group in a filter rail: optional leading icon, optional info tooltip,
|
|
22
|
+
// and a bottom divider so stacked sections read as distinct facets.
|
|
23
|
+
export function FacetSection({
|
|
24
|
+
icon: Icon,
|
|
25
|
+
title,
|
|
26
|
+
info,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
icon?: ComponentType<{ className?: string }>
|
|
30
|
+
title: string
|
|
31
|
+
info?: ReactNode
|
|
32
|
+
children: ReactNode
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<section className="border-b border-theme-border px-3 py-2">
|
|
36
|
+
<div className="mb-1.5 flex items-center gap-1.5">
|
|
37
|
+
{Icon && <Icon className="h-3.5 w-3.5 text-theme-text-tertiary" />}
|
|
38
|
+
<span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{title}</span>
|
|
39
|
+
{info && (
|
|
40
|
+
<Tooltip content={info} delay={150} position="right">
|
|
41
|
+
<Info className="h-3 w-3 cursor-default text-theme-text-tertiary/70 hover:text-theme-text-secondary" aria-label={`About ${title}`} />
|
|
42
|
+
</Tooltip>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
<div className="space-y-0.5">{children}</div>
|
|
46
|
+
</section>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A single multi-select filter toggle: leading status dot (tone-colored), label,
|
|
51
|
+
// trailing count. Active uses the skyhook accent.
|
|
52
|
+
export function FacetButton({
|
|
53
|
+
label,
|
|
54
|
+
count,
|
|
55
|
+
active,
|
|
56
|
+
tone = 'neutral',
|
|
57
|
+
tooltip,
|
|
58
|
+
onClick,
|
|
59
|
+
}: {
|
|
60
|
+
label: string
|
|
61
|
+
count?: number
|
|
62
|
+
active: boolean
|
|
63
|
+
tone?: FacetTone
|
|
64
|
+
tooltip?: ReactNode
|
|
65
|
+
onClick: () => void
|
|
66
|
+
}) {
|
|
67
|
+
const btn = (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={onClick}
|
|
71
|
+
aria-pressed={active}
|
|
72
|
+
className={clsx(
|
|
73
|
+
'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] transition-colors',
|
|
74
|
+
active
|
|
75
|
+
? 'bg-skyhook-500/15 text-skyhook-700 dark:text-skyhook-300'
|
|
76
|
+
: 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
<span className={clsx('h-2 w-2 shrink-0 rounded-full', DOT_CLASS[tone])} />
|
|
80
|
+
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
|
|
81
|
+
{count != null && count > 0 && <span className="tabular-nums text-theme-text-tertiary">{count}</span>}
|
|
82
|
+
</button>
|
|
83
|
+
)
|
|
84
|
+
return tooltip ? (
|
|
85
|
+
<Tooltip content={tooltip} delay={300} position="right" wrapperClassName="w-full">
|
|
86
|
+
{btn}
|
|
87
|
+
</Tooltip>
|
|
88
|
+
) : (
|
|
89
|
+
btn
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Convenience wrapper: a FacetSection of multi-select FacetButtons driven by a
|
|
94
|
+
// Set. Auto-hides zero-count options (quieter than a hard slice cap) unless
|
|
95
|
+
// hideZero is turned off.
|
|
96
|
+
export function Facet<T extends string>({
|
|
97
|
+
title,
|
|
98
|
+
icon,
|
|
99
|
+
info,
|
|
100
|
+
options,
|
|
101
|
+
selected,
|
|
102
|
+
onToggle,
|
|
103
|
+
hideZero = true,
|
|
104
|
+
}: {
|
|
105
|
+
title: string
|
|
106
|
+
icon?: ComponentType<{ className?: string }>
|
|
107
|
+
info?: ReactNode
|
|
108
|
+
options: { value: T; label: string; count: number; tone?: FacetTone; tooltip?: ReactNode }[]
|
|
109
|
+
selected: Set<T>
|
|
110
|
+
onToggle: (v: T) => void
|
|
111
|
+
hideZero?: boolean
|
|
112
|
+
}) {
|
|
113
|
+
const visible = hideZero ? options.filter((o) => o.count > 0) : options
|
|
114
|
+
if (visible.length === 0) return null
|
|
115
|
+
return (
|
|
116
|
+
<FacetSection icon={icon} title={title} info={info}>
|
|
117
|
+
{visible.map((o) => (
|
|
118
|
+
<FacetButton
|
|
119
|
+
key={o.value}
|
|
120
|
+
label={o.label}
|
|
121
|
+
count={o.count}
|
|
122
|
+
active={selected.has(o.value)}
|
|
123
|
+
tone={o.tone}
|
|
124
|
+
tooltip={o.tooltip}
|
|
125
|
+
onClick={() => onToggle(o.value)}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</FacetSection>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
2
|
+
import { ArrowLeft } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
// Standard view page header — icon + title + description, optional back button
|
|
5
|
+
// and right-aligned custom content (counts, tiles, settings, actions…). The
|
|
6
|
+
// consistent "where am I / what is this page" signal across views (Rancher
|
|
7
|
+
// Masthead / Headlamp SectionHeader pattern).
|
|
8
|
+
interface PageHeaderProps {
|
|
9
|
+
icon: ComponentType<{ className?: string }>
|
|
10
|
+
title: string
|
|
11
|
+
description?: string
|
|
12
|
+
/** Renders a back button to the left of the title when provided. */
|
|
13
|
+
onBack?: () => void
|
|
14
|
+
/** Right-aligned custom content — wraps onto multiple lines when it overflows
|
|
15
|
+
* (e.g. a row of count tiles). */
|
|
16
|
+
actions?: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex items-center gap-4">
|
|
22
|
+
{onBack && (
|
|
23
|
+
<button
|
|
24
|
+
onClick={onBack}
|
|
25
|
+
aria-label="Back"
|
|
26
|
+
className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors shrink-0"
|
|
27
|
+
>
|
|
28
|
+
<ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
|
|
29
|
+
</button>
|
|
30
|
+
)}
|
|
31
|
+
<div className="flex-1 min-w-0">
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
<Icon className="w-5 h-5 text-theme-text-secondary shrink-0" />
|
|
34
|
+
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{title}</h1>
|
|
35
|
+
</div>
|
|
36
|
+
{description && (
|
|
37
|
+
<p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate">{description}</p>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
{actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0">{actions}</div>}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|