@skyhook-io/k8s-ui 1.5.2 → 1.5.4

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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/components/audit/AuditAlerts.tsx +6 -0
  3. package/src/components/audit/AuditFindingsTable.tsx +83 -33
  4. package/src/components/gitops/ManagedResourcesList.tsx +2 -1
  5. package/src/components/logs/JsonLogLine.tsx +10 -7
  6. package/src/components/logs/LogCore.tsx +160 -63
  7. package/src/components/logs/LogToolbarSelects.tsx +17 -6
  8. package/src/components/logs/LogsViewer.tsx +8 -7
  9. package/src/components/logs/StructuredLogLine.tsx +46 -47
  10. package/src/components/logs/WorkloadLogsViewer.tsx +44 -34
  11. package/src/components/logs/log-palette.ts +222 -0
  12. package/src/components/logs/useLogBuffer.ts +6 -1
  13. package/src/components/resources/ResourcesView.tsx +183 -52
  14. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +3 -2
  15. package/src/components/resources/renderers/CertificateRenderer.tsx +4 -3
  16. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +4 -3
  18. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +2 -1
  20. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +4 -3
  21. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/SecretRenderer.tsx +10 -4
  23. package/src/components/resources/renderers/trivy-shared.tsx +3 -2
  24. package/src/components/resources/resource-utils-istio.ts +4 -3
  25. package/src/components/resources/resource-utils.ts +10 -2
  26. package/src/components/timeline/TimelineList.tsx +2 -1
  27. package/src/components/topology/GroupNode.tsx +2 -1
  28. package/src/components/topology/TopologyGraph.tsx +2 -1
  29. package/src/components/ui/Badge.tsx +2 -1
  30. package/src/components/ui/ClusterName.tsx +95 -0
  31. package/src/components/ui/EmptyState.tsx +118 -0
  32. package/src/components/ui/FilterPill.tsx +98 -0
  33. package/src/components/ui/ForceDeleteConfirmDialog.tsx +2 -1
  34. package/src/components/ui/ResourceBar.tsx +1 -1
  35. package/src/components/ui/Tooltip.tsx +43 -0
  36. package/src/components/ui/drawer-components.tsx +11 -0
  37. package/src/components/ui/index.ts +7 -0
  38. package/src/components/ui/provider-logos/aws-dark.png +0 -0
  39. package/src/components/ui/provider-logos/aws.png +0 -0
  40. package/src/components/ui/provider-logos/azure.svg +23 -0
  41. package/src/components/ui/provider-logos/gcp.png +0 -0
  42. package/src/components/ui/status-tone.test.ts +53 -0
  43. package/src/components/ui/status-tone.tsx +104 -0
  44. package/src/components/workload/WorkloadView.tsx +3 -3
  45. package/src/theme/components.css +7 -0
  46. package/src/utils/badge-colors.ts +1 -0
  47. package/src/utils/context-name.test.ts +106 -0
  48. package/src/utils/context-name.ts +25 -6
  49. package/src/utils/index.ts +1 -0
  50. package/src/utils/navigation.ts +4 -8
  51. package/src/utils/pluralize.test.ts +98 -0
  52. package/src/utils/pluralize.ts +60 -0
@@ -642,6 +642,17 @@ export function getKindColor(kind: string): string {
642
642
  return getKindColorClass(kind)
643
643
  }
644
644
 
645
+ // In drawer headers, kind sits next to a status pill ("Pod" / "Running"). Both
646
+ // filled creates two pills competing for attention even though kind is just
647
+ // metadata (the user already clicked the resource) and status is the signal.
648
+ // Stripping the fills makes kind read as a label and status as a value.
649
+ export function getKindColorOutline(kind: string): string {
650
+ return getKindColorClass(kind)
651
+ .replace(/\b(?:dark:)?bg-\S+/g, '')
652
+ .replace(/\s+/g, ' ')
653
+ .trim()
654
+ }
655
+
645
656
  export function formatKindName(kind: string): string {
646
657
  const k = kind.toLowerCase()
647
658
  const names: Record<string, string> = {
@@ -1,4 +1,11 @@
1
1
  export { Tooltip } from './Tooltip'
2
+ export { ClusterName } from './ClusterName'
3
+ export { EmptyState } from './EmptyState'
4
+ export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
5
+ export { FilterPill } from './FilterPill'
6
+ export type { FilterPillTone } from './FilterPill'
7
+ export { StatusDot, mapHealthToTone } from './status-tone'
8
+ export type { StatusTone, StatusDotProps } from './status-tone'
2
9
  export { DialogPortal } from './DialogPortal'
3
10
  export { ConfirmDialog } from './ConfirmDialog'
4
11
  export { HealthRing } from './HealthRing'
@@ -0,0 +1,23 @@
1
+ <svg width="150" height="150" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="e399c19f-b68f-429d-b176-18c2117ff73c" x1="-1032.172" x2="-1059.213" y1="145.312" y2="65.426" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#114a8b"/>
5
+ <stop offset="1" stop-color="#0669bc"/>
6
+ </linearGradient>
7
+ <linearGradient id="ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15" x1="-1023.725" x2="-1029.98" y1="108.083" y2="105.968" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
8
+ <stop offset="0" stop-opacity=".3"/>
9
+ <stop offset=".071" stop-opacity=".2"/>
10
+ <stop offset=".321" stop-opacity=".1"/>
11
+ <stop offset=".623" stop-opacity=".05"/>
12
+ <stop offset="1" stop-opacity="0"/>
13
+ </linearGradient>
14
+ <linearGradient id="a7fee970-a784-4bb1-af8d-63d18e5f7db9" x1="-1027.165" x2="-997.482" y1="147.642" y2="68.561" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
15
+ <stop offset="0" stop-color="#3ccbf4"/>
16
+ <stop offset="1" stop-color="#2892df"/>
17
+ </linearGradient>
18
+ </defs>
19
+ <path fill="url(#e399c19f-b68f-429d-b176-18c2117ff73c)" d="M33.338 6.544h26.038l-27.03 80.087a4.152 4.152 0 0 1-3.933 2.824H8.149a4.145 4.145 0 0 1-3.928-5.47L29.404 9.368a4.152 4.152 0 0 1 3.934-2.825z"/>
20
+ <path fill="#0078d4" d="M71.175 60.261h-41.29a1.911 1.911 0 0 0-1.305 3.309l26.532 24.764a4.171 4.171 0 0 0 2.846 1.121h23.38z"/>
21
+ <path fill="url(#ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15)" d="M33.338 6.544a4.118 4.118 0 0 0-3.943 2.879L4.252 83.917a4.14 4.14 0 0 0 3.908 5.538h20.787a4.443 4.443 0 0 0 3.41-2.9l5.014-14.777 17.91 16.705a4.237 4.237 0 0 0 2.666.972H81.24L71.024 60.261l-29.781.007L59.47 6.544z"/>
22
+ <path fill="url(#a7fee970-a784-4bb1-af8d-63d18e5f7db9)" d="M66.595 9.364a4.145 4.145 0 0 0-3.928-2.82H33.648a4.146 4.146 0 0 1 3.928 2.82l25.184 74.62a4.146 4.146 0 0 1-3.928 5.472h29.02a4.146 4.146 0 0 0 3.927-5.472z"/>
23
+ </svg>
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mapHealthToTone } from './status-tone'
3
+
4
+ // Inputs flow in from heterogeneous sources (Problems API, Audit findings,
5
+ // multi-cluster aggregation). A regression here is a silent visual
6
+ // downgrade — the wrong color renders, no error thrown. Pin the
7
+ // canonical mappings so a vocabulary drift on either side fails loudly.
8
+
9
+ describe('mapHealthToTone', () => {
10
+ it('maps healthy/ok/success/passing → healthy', () => {
11
+ expect(mapHealthToTone('healthy')).toBe('healthy')
12
+ expect(mapHealthToTone('ok')).toBe('healthy')
13
+ expect(mapHealthToTone('success')).toBe('healthy')
14
+ expect(mapHealthToTone('passing')).toBe('healthy')
15
+ })
16
+
17
+ it('maps degraded/warning/warn/medium → degraded', () => {
18
+ expect(mapHealthToTone('degraded')).toBe('degraded')
19
+ expect(mapHealthToTone('warning')).toBe('degraded')
20
+ expect(mapHealthToTone('warn')).toBe('degraded')
21
+ expect(mapHealthToTone('medium')).toBe('degraded')
22
+ })
23
+
24
+ it('maps high/alert → alert (the orange tier)', () => {
25
+ expect(mapHealthToTone('high')).toBe('alert')
26
+ expect(mapHealthToTone('alert')).toBe('alert')
27
+ })
28
+
29
+ it('maps unhealthy/danger/critical/error/failed → unhealthy', () => {
30
+ expect(mapHealthToTone('unhealthy')).toBe('unhealthy')
31
+ expect(mapHealthToTone('danger')).toBe('unhealthy')
32
+ expect(mapHealthToTone('critical')).toBe('unhealthy')
33
+ expect(mapHealthToTone('error')).toBe('unhealthy')
34
+ expect(mapHealthToTone('failed')).toBe('unhealthy')
35
+ })
36
+
37
+ it('maps neutral/info → neutral', () => {
38
+ expect(mapHealthToTone('neutral')).toBe('neutral')
39
+ expect(mapHealthToTone('info')).toBe('neutral')
40
+ })
41
+
42
+ it('falls back to unknown for unrecognized input', () => {
43
+ expect(mapHealthToTone('frobnicated')).toBe('unknown')
44
+ expect(mapHealthToTone('')).toBe('unknown')
45
+ expect(mapHealthToTone('low')).toBe('unknown')
46
+ })
47
+
48
+ it('is case-insensitive', () => {
49
+ expect(mapHealthToTone('CRITICAL')).toBe('unhealthy')
50
+ expect(mapHealthToTone('Healthy')).toBe('healthy')
51
+ expect(mapHealthToTone('HIGH')).toBe('alert')
52
+ })
53
+ })
@@ -0,0 +1,104 @@
1
+ import { type HealthLevel } from '../resources/resource-utils';
2
+
3
+ // StatusDot + mapHealthToTone are typed helpers over the canonical OSS
4
+ // status vocabulary defined in `packages/k8s-ui/src/theme/components.css`.
5
+ // The `.status-*` CSS classes (.status-healthy / .status-degraded /
6
+ // .status-alert / .status-unhealthy / .status-neutral / .status-unknown)
7
+ // are the source of truth for color; this file just adds dot-color
8
+ // tokens and a tone-normalization helper.
9
+ //
10
+ // For pill-shaped status badges, use the canonical pattern directly:
11
+ //
12
+ // <span className={`badge ${healthColors[tone]}`}>...</span>
13
+ //
14
+ // (no typed component wrapper — that pattern is widely used across OSS
15
+ // already and adding a wrapper would split the call sites without
16
+ // fitting the count-badge / button-pill / icon-pill variants OSS uses).
17
+ //
18
+ // Vocabulary (escalating urgency):
19
+ // healthy — running / live / passing (emerald)
20
+ // degraded — warning / slow / advisory (amber)
21
+ // alert — high-severity but not foundationally broken (orange)
22
+ // unhealthy — critical / failing / blocked (red)
23
+ // neutral — informational / scope counter (sky)
24
+ // unknown — terminal / unknown / no-action-needed (slate)
25
+ //
26
+ // `alert` is the intermediate tier between degraded and unhealthy. Used
27
+ // when the data carries a 3-step severity gradient (Problems pages,
28
+ // Audit findings, Cert expiry buckets) — without it, "high" and either
29
+ // "critical" or "medium" collapse into the same color.
30
+
31
+ // `StatusTone` is intentionally a name for `HealthLevel` at call sites
32
+ // where the concern is presentation (which color/dot to render) rather
33
+ // than the underlying health state — `tone: StatusTone` reads cleaner
34
+ // than `tone: HealthLevel`. The two are structurally identical today;
35
+ // keeping a separate name preserves the option to diverge later (e.g.
36
+ // a future `brand` tone that isn't a health state).
37
+ export type StatusTone = HealthLevel;
38
+
39
+ // Solid dot colors for StatusDot (the .status-* CSS classes are pill
40
+ // backgrounds, too soft for a tiny solid indicator). Kept in lockstep
41
+ // with the CSS palette so tone names stay aligned.
42
+ const DOT_CLASS: Record<StatusTone, string> = {
43
+ healthy: 'bg-emerald-500',
44
+ degraded: 'bg-amber-500',
45
+ alert: 'bg-orange-500',
46
+ unhealthy: 'bg-rose-500',
47
+ neutral: 'bg-sky-500',
48
+ unknown: 'bg-slate-400',
49
+ };
50
+
51
+ // Normalize the variety of severity / health vocabularies that flow in
52
+ // from APIs (Problems, Audit, multi-cluster aggregation endpoints) onto
53
+ // a single tone. Inputs are case-insensitive. Returns 'unknown' for
54
+ // unrecognized values.
55
+ export function mapHealthToTone(input: string): StatusTone {
56
+ switch (input.toLowerCase()) {
57
+ case 'healthy':
58
+ case 'ok':
59
+ case 'success':
60
+ case 'passing':
61
+ return 'healthy';
62
+ case 'degraded':
63
+ case 'warning':
64
+ case 'warn':
65
+ case 'medium':
66
+ return 'degraded';
67
+ case 'high':
68
+ case 'alert':
69
+ return 'alert';
70
+ case 'unhealthy':
71
+ case 'danger':
72
+ case 'critical':
73
+ case 'error':
74
+ case 'failed':
75
+ return 'unhealthy';
76
+ case 'neutral':
77
+ case 'info':
78
+ return 'neutral';
79
+ default:
80
+ return 'unknown';
81
+ }
82
+ }
83
+
84
+ export interface StatusDotProps {
85
+ tone: StatusTone;
86
+ /** Dot diameter. Default 'sm'. */
87
+ size?: 'xs' | 'sm' | 'md';
88
+ className?: string;
89
+ }
90
+
91
+ const DOT_SIZE: Record<NonNullable<StatusDotProps['size']>, string> = {
92
+ xs: 'h-1 w-1',
93
+ sm: 'h-1.5 w-1.5',
94
+ md: 'h-2 w-2',
95
+ };
96
+
97
+ export function StatusDot({ tone, size = 'sm', className = '' }: StatusDotProps) {
98
+ return (
99
+ <span
100
+ className={`inline-block rounded-full ${DOT_SIZE[size]} ${DOT_CLASS[tone]} ${className}`}
101
+ aria-hidden
102
+ />
103
+ );
104
+ }
@@ -39,7 +39,7 @@ import {
39
39
  import { ResourceActionsBar } from '../shared/ResourceActionsBar'
40
40
  import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
41
41
  import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
42
- import { getKindColor, formatKindName } from '../ui/drawer-components'
42
+ import { getKindColorOutline, formatKindName } from '../ui/drawer-components'
43
43
 
44
44
  type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
45
45
 
@@ -360,7 +360,7 @@ export function WorkloadView({
360
360
  {/* Top row: badges and controls */}
361
361
  <div className="flex items-center justify-between px-4 pt-3 pb-2">
362
362
  <div className="flex items-center gap-2 flex-wrap">
363
- <span className={clsx('badge', getKindColor(apiKind))}>
363
+ <span className={clsx('badge', getKindColorOutline(apiKind))}>
364
364
  {formatKindName(apiKind)}
365
365
  </span>
366
366
  {status && (
@@ -497,7 +497,7 @@ export function WorkloadView({
497
497
  </button>
498
498
  </div>
499
499
  <div className="flex items-center gap-3 text-sm text-theme-text-secondary">
500
- <span className={clsx('badge', getKindColor(apiKind))}>
500
+ <span className={clsx('badge', getKindColorOutline(apiKind))}>
501
501
  {formatKindName(apiKind)}
502
502
  </span>
503
503
  {status && (
@@ -146,6 +146,13 @@
146
146
  border-color: light-dark(#fcd34d, rgba(120, 53, 15, 0.4));
147
147
  }
148
148
 
149
+ /* Alert - orange (between degraded and unhealthy; "high severity"). */
150
+ .status-alert {
151
+ background-color: light-dark(#ffedd5, rgba(124, 45, 18, 0.5));
152
+ color: light-dark(#c2410c, #fdba74);
153
+ border-color: light-dark(#fdba74, rgba(124, 45, 18, 0.4));
154
+ }
155
+
149
156
  /* Unhealthy/Error - red */
150
157
  .status-unhealthy {
151
158
  background-color: light-dark(#fee2e2, rgba(127, 29, 29, 0.5));
@@ -36,6 +36,7 @@ export const OPERATION_BADGE_COLORS: Record<string, string> = {
36
36
  export const HEALTH_BADGE_COLORS: Record<string, string> = {
37
37
  healthy: BADGE_SEVERITY_COLORS.success,
38
38
  degraded: BADGE_SEVERITY_COLORS.warning,
39
+ alert: BADGE_SEVERITY_COLORS.alert,
39
40
  unhealthy: BADGE_SEVERITY_COLORS.error,
40
41
  unknown: BADGE_SEVERITY_COLORS.neutral,
41
42
  }
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseContextName } from './context-name'
3
+
4
+ describe('parseContextName', () => {
5
+ describe('GKE', () => {
6
+ it('parses zonal cluster context', () => {
7
+ const r = parseContextName('gke_my-project_us-east1-b_prod-cluster')
8
+ expect(r.provider).toBe('GKE')
9
+ expect(r.account).toBe('my-project')
10
+ expect(r.region).toBe('us-east1-b')
11
+ expect(r.clusterName).toBe('prod-cluster')
12
+ })
13
+
14
+ it('parses regional cluster context', () => {
15
+ const r = parseContextName('gke_my-project_us-central1_my-cluster')
16
+ expect(r.provider).toBe('GKE')
17
+ expect(r.region).toBe('us-central1')
18
+ expect(r.clusterName).toBe('my-cluster')
19
+ })
20
+
21
+ it('handles cluster names with underscores in the suffix', () => {
22
+ const r = parseContextName('gke_proj_us-east1-b_my_funky_cluster')
23
+ expect(r.provider).toBe('GKE')
24
+ expect(r.clusterName).toBe('my_funky_cluster')
25
+ })
26
+
27
+ it('does NOT match arbitrary 3-underscore strings (regression)', () => {
28
+ // The pre-fix regex `^gke_([^_]+)_([^_]+)_(.+)$` matched anything
29
+ // gke-prefixed with three underscores. Tightened: zone must contain
30
+ // a digit (every real GCP zone does).
31
+ const r = parseContextName('gke_a_b_c')
32
+ expect(r.provider).toBeNull()
33
+ })
34
+
35
+ it('rejects non-GKE-zone middle segment', () => {
36
+ const r = parseContextName('gke_proj_notazone_cluster')
37
+ expect(r.provider).toBeNull()
38
+ expect(r.clusterName).toBe('gke_proj_notazone_cluster')
39
+ })
40
+ })
41
+
42
+ describe('EKS ARN', () => {
43
+ it('parses standard EKS ARN', () => {
44
+ const r = parseContextName('arn:aws:eks:us-east-1:123456789012:cluster/my-prod')
45
+ expect(r.provider).toBe('EKS')
46
+ expect(r.account).toBe('123456789012')
47
+ expect(r.region).toBe('us-east-1')
48
+ expect(r.clusterName).toBe('my-prod')
49
+ })
50
+
51
+ it('handles cluster names with hyphens', () => {
52
+ const r = parseContextName('arn:aws:eks:eu-central-1:982081053473:cluster/us-east-1-nonprod')
53
+ expect(r.clusterName).toBe('us-east-1-nonprod')
54
+ })
55
+ })
56
+
57
+ describe('eksctl format', () => {
58
+ it('parses user@cluster.region.eksctl.io', () => {
59
+ const r = parseContextName('admin@my-cluster.us-east-1.eksctl.io')
60
+ expect(r.provider).toBe('EKS')
61
+ expect(r.account).toBe('eksctl')
62
+ expect(r.region).toBe('us-east-1')
63
+ expect(r.clusterName).toBe('my-cluster')
64
+ })
65
+ })
66
+
67
+ describe('AKS', () => {
68
+ it('parses clusterUser_<rg>_<name>', () => {
69
+ const r = parseContextName('clusterUser_my-rg_my-cluster')
70
+ expect(r.provider).toBe('AKS')
71
+ expect(r.account).toBe('my-rg')
72
+ expect(r.clusterName).toBe('my-cluster')
73
+ })
74
+
75
+ it('parses clusterAdmin_<rg>_<name>', () => {
76
+ const r = parseContextName('clusterAdmin_prod-rg_prod-cluster')
77
+ expect(r.provider).toBe('AKS')
78
+ expect(r.account).toBe('prod-rg')
79
+ expect(r.clusterName).toBe('prod-cluster')
80
+ })
81
+
82
+ it('does NOT mis-tag user-named clusters that happen to contain "aks"', () => {
83
+ // Regression: pre-fix used substring match for "aks", so these
84
+ // false-positive matched.
85
+ expect(parseContextName('tracks-prod').provider).toBeNull()
86
+ expect(parseContextName('my-aks-experiment').provider).toBeNull()
87
+ expect(parseContextName('aks').provider).toBeNull()
88
+ })
89
+ })
90
+
91
+ describe('unknown', () => {
92
+ it('returns user-named cluster unchanged', () => {
93
+ const r = parseContextName('my-staging-cluster')
94
+ expect(r.provider).toBeNull()
95
+ expect(r.clusterName).toBe('my-staging-cluster')
96
+ expect(r.raw).toBe('my-staging-cluster')
97
+ })
98
+
99
+ it('returns empty-shape unknown for unparseable input', () => {
100
+ const r = parseContextName('something-else-entirely')
101
+ expect(r.provider).toBeNull()
102
+ expect(r.account).toBeNull()
103
+ expect(r.region).toBeNull()
104
+ })
105
+ })
106
+ })
@@ -11,8 +11,19 @@ export interface ParsedContextName {
11
11
  * and cluster name. Used by the context switcher and error views.
12
12
  */
13
13
  export function parseContextName(name: string): ParsedContextName {
14
- // GKE format: gke_{project}_{region}_{cluster-name}
15
- const gkeMatch = name.match(/^gke_([^_]+)_([^_]+)_(.+)$/)
14
+ // GKE format: gke_{project}_{region|zone}_{cluster-name}
15
+ // Region is `<continent>-<direction><number>` (e.g. us-east1, europe-west1,
16
+ // asia-northeast1). Zonal clusters append `-<letter>` (e.g. us-east1-b).
17
+ // We require the middle segment to contain at least one digit; that rules
18
+ // out garbage like `gke_a_b_c` and `gke_proj_notazone_cluster` while
19
+ // matching all real GCP zone shapes without enumerating them.
20
+ //
21
+ // Linear-time form: a non-consuming lookahead asserts "this segment
22
+ // contains a digit" up to the next `_`, then a single greedy capture
23
+ // consumes the segment. The earlier `[a-z0-9-]*\d[a-z0-9-]*` shape had
24
+ // overlapping repeats around the digit, which CodeQL flagged as a
25
+ // polynomial backtracking risk.
26
+ const gkeMatch = name.match(/^gke_([a-z][a-z0-9-]+)_(?=[a-z0-9-]*\d)([a-z][a-z0-9-]*)_(.+)$/)
16
27
  if (gkeMatch) {
17
28
  const [, project, region, cluster] = gkeMatch
18
29
  return {
@@ -50,13 +61,21 @@ export function parseContextName(name: string): ParsedContextName {
50
61
  }
51
62
  }
52
63
 
53
- // AKS format: try to detect
54
- if (name.toLowerCase().includes('aks') || name.includes('.azure.') || name.includes('azurecr')) {
64
+ // AKS context format produced by `az aks get-credentials`:
65
+ // clusterUser_<resourceGroup>_<clusterName>
66
+ // clusterAdmin_<resourceGroup>_<clusterName>
67
+ // Strict regex — the prior heuristic (substring match for "aks") was a
68
+ // correctness bug that mis-tagged user-named clusters like "tracks-prod"
69
+ // (contains "aks") or "my-aks-experiment". Better to fall through to
70
+ // unknown than to mis-parse.
71
+ const aksMatch = name.match(/^cluster(?:User|Admin)_([^_]+)_(.+)$/)
72
+ if (aksMatch) {
73
+ const [, resourceGroup, cluster] = aksMatch
55
74
  return {
56
75
  provider: 'AKS',
57
- account: null,
76
+ account: resourceGroup,
58
77
  region: null,
59
- clusterName: name,
78
+ clusterName: cluster,
60
79
  raw: name,
61
80
  }
62
81
  }
@@ -1,4 +1,5 @@
1
1
  export * from './format'
2
+ export * from './pluralize'
2
3
  export * from './badge-colors'
3
4
  export * from './resource-icons'
4
5
  export * from './navigation'
@@ -1,4 +1,5 @@
1
1
  import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
2
+ import { englishPlural } from './pluralize'
2
3
 
3
4
  /**
4
5
  * Canonical callback type for navigating to a resource.
@@ -93,14 +94,9 @@ export function kindToPlural(kind: string): string {
93
94
  }
94
95
  if (aliases[kindLower]) return aliases[kindLower]
95
96
 
96
- // Fallback: English pluralization rules (covers *Class→*classes, *Policy→*policies, *Repository→*repositories, etc.)
97
- if (kindLower.endsWith('s') || kindLower.endsWith('x') || kindLower.endsWith('ch') || kindLower.endsWith('sh')) {
98
- return kindLower + 'es'
99
- }
100
- if (kindLower.endsWith('y') && !/[aeiou]y$/.test(kindLower)) {
101
- return kindLower.slice(0, -1) + 'ies'
102
- }
103
- return kindLower + 's'
97
+ // Fallback: English pluralization rules (shared with pluralize() in
98
+ // utils/pluralize.ts so a rule change updates both call paths).
99
+ return englishPlural(kindLower)
104
100
  }
105
101
 
106
102
  /**
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { englishPlural, pluralNoun, pluralize } from './pluralize'
3
+
4
+ describe('englishPlural', () => {
5
+ describe('default +s', () => {
6
+ it('handles common nouns', () => {
7
+ expect(englishPlural('cluster')).toBe('clusters')
8
+ expect(englishPlural('cert')).toBe('certs')
9
+ expect(englishPlural('chart')).toBe('charts')
10
+ })
11
+ })
12
+
13
+ describe('s/x/ch/sh → +es', () => {
14
+ it('handles s ending', () => {
15
+ expect(englishPlural('class')).toBe('classes')
16
+ expect(englishPlural('ingress')).toBe('ingresses')
17
+ })
18
+
19
+ it('handles x ending', () => {
20
+ expect(englishPlural('box')).toBe('boxes')
21
+ })
22
+
23
+ it('handles ch ending', () => {
24
+ expect(englishPlural('match')).toBe('matches')
25
+ })
26
+
27
+ it('handles sh ending', () => {
28
+ expect(englishPlural('brush')).toBe('brushes')
29
+ })
30
+ })
31
+
32
+ describe('consonant-y → -y +ies', () => {
33
+ it('handles policy', () => {
34
+ expect(englishPlural('policy')).toBe('policies')
35
+ })
36
+
37
+ it('handles directory', () => {
38
+ expect(englishPlural('directory')).toBe('directories')
39
+ })
40
+
41
+ it('preserves vowel-y → +s (does not strip)', () => {
42
+ expect(englishPlural('day')).toBe('days')
43
+ expect(englishPlural('boy')).toBe('boys')
44
+ })
45
+ })
46
+
47
+ describe('case insensitivity in detection', () => {
48
+ // The detection regex is case-insensitive (recognizes Class as ending
49
+ // in 's'-class, POLICY as consonant-y) but the rule appends lowercase
50
+ // suffixes. In practice all callers normalize to lowercase before
51
+ // calling — kindToPlural() lowercases its input; pluralize() takes
52
+ // user-controlled nouns which are typically lowercase.
53
+ it('detects rule-applicable suffixes regardless of case', () => {
54
+ expect(englishPlural('Class')).toBe('Classes') // +es appended lowercase
55
+ expect(englishPlural('Box')).toBe('Boxes')
56
+ })
57
+ })
58
+ })
59
+
60
+ describe('pluralNoun', () => {
61
+ it('returns singular for n===1', () => {
62
+ expect(pluralNoun(1, 'cluster')).toBe('cluster')
63
+ })
64
+
65
+ it('returns plural for n!==1', () => {
66
+ expect(pluralNoun(0, 'cluster')).toBe('clusters')
67
+ expect(pluralNoun(2, 'cluster')).toBe('clusters')
68
+ expect(pluralNoun(99, 'cluster')).toBe('clusters')
69
+ })
70
+
71
+ it('uses englishPlural rules by default', () => {
72
+ expect(pluralNoun(2, 'policy')).toBe('policies')
73
+ expect(pluralNoun(2, 'class')).toBe('classes')
74
+ })
75
+
76
+ it('honors explicit override for irregular plurals', () => {
77
+ expect(pluralNoun(2, 'index', 'indices')).toBe('indices')
78
+ expect(pluralNoun(1, 'index', 'indices')).toBe('index')
79
+ })
80
+ })
81
+
82
+ describe('pluralize', () => {
83
+ it('formats count + noun', () => {
84
+ expect(pluralize(0, 'cluster')).toBe('0 clusters')
85
+ expect(pluralize(1, 'cluster')).toBe('1 cluster')
86
+ expect(pluralize(5, 'cluster')).toBe('5 clusters')
87
+ })
88
+
89
+ it('handles irregular plurals via override', () => {
90
+ expect(pluralize(3, 'index', 'indices')).toBe('3 indices')
91
+ })
92
+
93
+ it('applies English rules without override', () => {
94
+ expect(pluralize(2, 'policy')).toBe('2 policies')
95
+ expect(pluralize(2, 'class')).toBe('2 classes')
96
+ expect(pluralize(2, 'box')).toBe('2 boxes')
97
+ })
98
+ })
@@ -0,0 +1,60 @@
1
+ // Pluralization helpers shared across the package. Layered:
2
+ //
3
+ // englishPlural(noun) — lowest level. Applies regular English
4
+ // rules: s/x/ch/sh → +es; consonant-y → ies;
5
+ // default +s.
6
+ // pluralNoun(n, sing, plural?) — count-aware plural form (just the noun).
7
+ // Returns sing when n===1, else plural
8
+ // (or englishPlural(sing) if not specified).
9
+ // pluralize(n, sing, plural?) — count + plural noun phrase. "1 cluster"
10
+ // / "5 clusters" / "0 issues".
11
+ //
12
+ // kindToPlural() in navigation.ts also delegates to englishPlural for its
13
+ // English fallback, so both call paths share rule changes automatically.
14
+
15
+ /**
16
+ * Apply English regular-pluralization rules to a noun.
17
+ *
18
+ * englishPlural('class') → 'classes' (s/x/ch/sh → +es)
19
+ * englishPlural('policy') → 'policies' (consonant-y → -y +ies)
20
+ * englishPlural('boy') → 'boys' (vowel-y → +s)
21
+ * englishPlural('cluster')→ 'clusters' (default +s)
22
+ *
23
+ * Case-insensitive matching but preserves input case. Doesn't handle
24
+ * irregular plurals (child/children, mouse/mice) — callers should pass
25
+ * an explicit override for those.
26
+ */
27
+ export function englishPlural(noun: string): string {
28
+ if (/(?:s|x|ch|sh)$/i.test(noun)) return noun + 'es';
29
+ if (/[^aeiou]y$/i.test(noun)) return noun.slice(0, -1) + 'ies';
30
+ return noun + 's';
31
+ }
32
+
33
+ /**
34
+ * Count-aware plural noun. Returns the singular when n===1, else the
35
+ * plural form (passed-in or derived via englishPlural).
36
+ *
37
+ * pluralNoun(1, 'cluster') → 'cluster'
38
+ * pluralNoun(5, 'cluster') → 'clusters'
39
+ * pluralNoun(0, 'cluster') → 'clusters' (zero is plural in English)
40
+ * pluralNoun(2, 'index', 'indices') → 'indices' (irregular override)
41
+ */
42
+ export function pluralNoun(n: number, singular: string, plural?: string): string {
43
+ return n === 1 ? singular : (plural ?? englishPlural(singular));
44
+ }
45
+
46
+ /**
47
+ * Format a count + noun phrase with proper agreement.
48
+ *
49
+ * pluralize(1, 'cluster') → '1 cluster'
50
+ * pluralize(5, 'cluster') → '5 clusters'
51
+ * pluralize(0, 'issue') → '0 issues'
52
+ * pluralize(3, 'index', 'indices') → '3 indices'
53
+ *
54
+ * For Kubernetes resource kinds with structured plural mappings (e.g.
55
+ * NetworkPolicy → networkpolicies), use kindToPlural() from
56
+ * navigation.ts to produce the noun, then pass it as the plural override.
57
+ */
58
+ export function pluralize(n: number, singular: string, plural?: string): string {
59
+ return `${n} ${pluralNoun(n, singular, plural)}`;
60
+ }