@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.
- package/package.json +1 -1
- package/src/components/audit/AuditAlerts.tsx +6 -0
- package/src/components/audit/AuditFindingsTable.tsx +83 -33
- package/src/components/gitops/ManagedResourcesList.tsx +2 -1
- package/src/components/logs/JsonLogLine.tsx +10 -7
- package/src/components/logs/LogCore.tsx +160 -63
- package/src/components/logs/LogToolbarSelects.tsx +17 -6
- package/src/components/logs/LogsViewer.tsx +8 -7
- package/src/components/logs/StructuredLogLine.tsx +46 -47
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -34
- package/src/components/logs/log-palette.ts +222 -0
- package/src/components/logs/useLogBuffer.ts +6 -1
- package/src/components/resources/ResourcesView.tsx +183 -52
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +4 -3
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +4 -3
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +2 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +4 -3
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -4
- package/src/components/resources/renderers/trivy-shared.tsx +3 -2
- package/src/components/resources/resource-utils-istio.ts +4 -3
- package/src/components/resources/resource-utils.ts +10 -2
- package/src/components/timeline/TimelineList.tsx +2 -1
- package/src/components/topology/GroupNode.tsx +2 -1
- package/src/components/topology/TopologyGraph.tsx +2 -1
- package/src/components/ui/Badge.tsx +2 -1
- package/src/components/ui/ClusterName.tsx +95 -0
- package/src/components/ui/EmptyState.tsx +118 -0
- package/src/components/ui/FilterPill.tsx +98 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +2 -1
- package/src/components/ui/ResourceBar.tsx +1 -1
- package/src/components/ui/Tooltip.tsx +43 -0
- package/src/components/ui/drawer-components.tsx +11 -0
- package/src/components/ui/index.ts +7 -0
- package/src/components/ui/provider-logos/aws-dark.png +0 -0
- package/src/components/ui/provider-logos/aws.png +0 -0
- package/src/components/ui/provider-logos/azure.svg +23 -0
- package/src/components/ui/provider-logos/gcp.png +0 -0
- package/src/components/ui/status-tone.test.ts +53 -0
- package/src/components/ui/status-tone.tsx +104 -0
- package/src/components/workload/WorkloadView.tsx +3 -3
- package/src/theme/components.css +7 -0
- package/src/utils/badge-colors.ts +1 -0
- package/src/utils/context-name.test.ts +106 -0
- package/src/utils/context-name.ts +25 -6
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.ts +4 -8
- package/src/utils/pluralize.test.ts +98 -0
- 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'
|
|
Binary file
|
|
Binary file
|
|
@@ -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>
|
|
Binary file
|
|
@@ -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 {
|
|
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',
|
|
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',
|
|
500
|
+
<span className={clsx('badge', getKindColorOutline(apiKind))}>
|
|
501
501
|
{formatKindName(apiKind)}
|
|
502
502
|
</span>
|
|
503
503
|
{status && (
|
package/src/theme/components.css
CHANGED
|
@@ -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
|
-
|
|
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
|
|
54
|
-
|
|
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:
|
|
76
|
+
account: resourceGroup,
|
|
58
77
|
region: null,
|
|
59
|
-
clusterName:
|
|
78
|
+
clusterName: cluster,
|
|
60
79
|
raw: name,
|
|
61
80
|
}
|
|
62
81
|
}
|
package/src/utils/index.ts
CHANGED
package/src/utils/navigation.ts
CHANGED
|
@@ -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 (
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|