@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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getPrometheusRuleGroupCount,
|
|
10
10
|
} from '../resource-utils-prometheus'
|
|
11
11
|
import type { PrometheusRuleGroup, PrometheusRule, PrometheusAlertRule, PrometheusRecordingRule } from '../resource-utils-prometheus'
|
|
12
|
+
import { pluralize, pluralNoun } from '../../../utils/pluralize'
|
|
12
13
|
|
|
13
14
|
interface PrometheusRuleRendererProps {
|
|
14
15
|
data: any
|
|
@@ -135,13 +136,13 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
|
|
|
135
136
|
</span>
|
|
136
137
|
)}
|
|
137
138
|
<span className="text-xs text-theme-text-tertiary">
|
|
138
|
-
{searchTerm ? `${filteredRules.length}/${group.ruleCount}` : group.ruleCount}
|
|
139
|
+
{searchTerm ? `${filteredRules.length}/${group.ruleCount}` : group.ruleCount} {pluralNoun(group.ruleCount, 'rule')}
|
|
139
140
|
</span>
|
|
140
141
|
</div>
|
|
141
142
|
</button>
|
|
142
143
|
{!expanded && (
|
|
143
144
|
<div className="text-xs text-theme-text-secondary mt-1 ml-5.5 flex gap-3">
|
|
144
|
-
{group.alertCount > 0 && <span>{group.alertCount
|
|
145
|
+
{group.alertCount > 0 && <span>{pluralize(group.alertCount, 'alert')}</span>}
|
|
145
146
|
{group.recordCount > 0 && <span>{group.recordCount} recording</span>}
|
|
146
147
|
</div>
|
|
147
148
|
)}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
|
-
import { AlertTriangle, Copy, Check, Shield, Pencil, Save, XCircle, RefreshCw } from 'lucide-react'
|
|
2
|
+
import { AlertTriangle, Copy, Check, Shield, Pencil, Save, XCircle, RefreshCw, Eye, EyeOff } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { stringify as yamlStringify } from 'yaml'
|
|
5
5
|
import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
|
|
6
6
|
import { ConfirmDialog } from '../../ui/ConfirmDialog'
|
|
7
7
|
import type { SecretCertificateInfo, CertificateInfo } from '../../../types'
|
|
8
|
+
import { pluralize } from '../../../utils/pluralize'
|
|
8
9
|
|
|
9
10
|
interface SecretRendererProps {
|
|
10
11
|
data: any
|
|
@@ -125,7 +126,7 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
|
|
|
125
126
|
{leafCert && !leafCert.expired && leafCert.daysLeft <= 7 && (
|
|
126
127
|
<AlertBanner
|
|
127
128
|
variant="error"
|
|
128
|
-
title={`Certificate expires in ${leafCert.daysLeft
|
|
129
|
+
title={`Certificate expires in ${pluralize(leafCert.daysLeft, 'day')}`}
|
|
129
130
|
message="Check that cert-manager or your CA is renewing this certificate."
|
|
130
131
|
/>
|
|
131
132
|
)}
|
|
@@ -133,7 +134,7 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
|
|
|
133
134
|
{leafCert && !leafCert.expired && leafCert.daysLeft > 7 && leafCert.daysLeft <= 30 && (
|
|
134
135
|
<AlertBanner
|
|
135
136
|
variant="warning"
|
|
136
|
-
title={`Certificate expires in ${leafCert.daysLeft
|
|
137
|
+
title={`Certificate expires in ${pluralize(leafCert.daysLeft, 'day')}`}
|
|
137
138
|
message="Renewal should happen automatically before expiry."
|
|
138
139
|
/>
|
|
139
140
|
)}
|
|
@@ -190,8 +191,13 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
|
|
|
190
191
|
{!isEditing && (
|
|
191
192
|
<button
|
|
192
193
|
onClick={() => toggleReveal(key)}
|
|
193
|
-
className="text-xs text-theme-text-secondary hover:text-theme-text-primary px-1.5 py-0.5 rounded hover:bg-theme-elevated transition-colors"
|
|
194
|
+
className="inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary px-1.5 py-0.5 rounded hover:bg-theme-elevated transition-colors"
|
|
194
195
|
>
|
|
196
|
+
{revealed.has(key) ? (
|
|
197
|
+
<EyeOff className="w-3.5 h-3.5" />
|
|
198
|
+
) : (
|
|
199
|
+
<Eye className="w-3.5 h-3.5" />
|
|
200
|
+
)}
|
|
195
201
|
{revealed.has(key) ? 'Hide' : 'Reveal'}
|
|
196
202
|
</button>
|
|
197
203
|
)}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
VULN_SEVERITY_BAR,
|
|
5
5
|
VULN_SEVERITY_TEXT,
|
|
6
6
|
} from '../../../utils/badge-colors'
|
|
7
|
+
import { pluralNoun } from '../../../utils/pluralize'
|
|
7
8
|
|
|
8
9
|
// Re-export from centralized badge-colors under the legacy names used by Trivy renderers
|
|
9
10
|
export const SEVERITY_BADGE_COLORS = VULN_SEVERITY_BADGE
|
|
@@ -33,7 +34,7 @@ export function TrivyAlertBanner({ critical, high, noun }: { critical: number; h
|
|
|
33
34
|
return (
|
|
34
35
|
<AlertBanner
|
|
35
36
|
variant="error"
|
|
36
|
-
title={`${critical} critical ${
|
|
37
|
+
title={`${critical} critical ${pluralNoun(critical, noun)}`}
|
|
37
38
|
message="Critical issues should be addressed immediately."
|
|
38
39
|
/>
|
|
39
40
|
)
|
|
@@ -42,7 +43,7 @@ export function TrivyAlertBanner({ critical, high, noun }: { critical: number; h
|
|
|
42
43
|
return (
|
|
43
44
|
<AlertBanner
|
|
44
45
|
variant="warning"
|
|
45
|
-
title={`${high} high-severity ${
|
|
46
|
+
title={`${high} high-severity ${pluralNoun(high, noun)}`}
|
|
46
47
|
message="Consider addressing these issues to improve security posture."
|
|
47
48
|
/>
|
|
48
49
|
)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { StatusBadge } from './resource-utils'
|
|
4
4
|
import { healthColors } from './resource-utils'
|
|
5
|
+
import { pluralize } from '../../utils/pluralize'
|
|
5
6
|
|
|
6
7
|
// ============================================================================
|
|
7
8
|
// SHARED HELPERS
|
|
@@ -134,7 +135,7 @@ export function getDestinationRuleStatus(resource: any): StatusBadge {
|
|
|
134
135
|
const trafficPolicy = spec.trafficPolicy
|
|
135
136
|
|
|
136
137
|
if (subsets.length > 0) {
|
|
137
|
-
return { text:
|
|
138
|
+
return { text: pluralize(subsets.length, 'Subset'), color: healthColors.healthy, level: 'healthy' }
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
if (trafficPolicy) {
|
|
@@ -332,9 +333,9 @@ export function getAuthorizationPolicyStatus(resource: any): StatusBadge {
|
|
|
332
333
|
|
|
333
334
|
switch (action) {
|
|
334
335
|
case 'ALLOW':
|
|
335
|
-
return { text: `Allow (${rules.length
|
|
336
|
+
return { text: `Allow (${pluralize(rules.length, 'rule')})`, color: healthColors.healthy, level: 'healthy' }
|
|
336
337
|
case 'DENY':
|
|
337
|
-
return { text: `Deny (${rules.length
|
|
338
|
+
return { text: `Deny (${pluralize(rules.length, 'rule')})`, color: healthColors.unhealthy, level: 'unhealthy' }
|
|
338
339
|
case 'CUSTOM':
|
|
339
340
|
return { text: 'Custom', color: healthColors.degraded, level: 'degraded' }
|
|
340
341
|
case 'AUDIT':
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Utility functions for resource display in tables
|
|
2
2
|
|
|
3
3
|
import { formatCPUString, formatMemoryString, formatBytes } from '../../utils/format'
|
|
4
|
+
import { pluralize } from '../../utils/pluralize'
|
|
4
5
|
|
|
5
6
|
// Import functions from sub-modules used internally by getCellFilterValue
|
|
6
7
|
import { getCertificateStatus, getCertificateRequestStatus, getClusterIssuerStatus, getClusterIssuerType, getOrderState, getChallengeState, getChallengeType } from './resource-utils-certmanager'
|
|
@@ -16,7 +17,13 @@ import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternal
|
|
|
16
17
|
// STATUS & HEALTH UTILITIES
|
|
17
18
|
// ============================================================================
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// Six health levels in escalating urgency order:
|
|
21
|
+
// healthy < neutral < unknown < degraded < alert < unhealthy
|
|
22
|
+
// `alert` (orange) is the intermediate tier between degraded (amber) and
|
|
23
|
+
// unhealthy (red). Used for severity gradients like Problems/Audit
|
|
24
|
+
// (critical/high/medium → unhealthy/alert/degraded), where collapsing
|
|
25
|
+
// `high` into either neighbor erases real signal.
|
|
26
|
+
export type HealthLevel = 'healthy' | 'degraded' | 'alert' | 'unhealthy' | 'unknown' | 'neutral'
|
|
20
27
|
|
|
21
28
|
export interface StatusBadge {
|
|
22
29
|
text: string
|
|
@@ -28,6 +35,7 @@ export interface StatusBadge {
|
|
|
28
35
|
export const healthColors: Record<HealthLevel, string> = {
|
|
29
36
|
healthy: 'status-healthy',
|
|
30
37
|
degraded: 'status-degraded',
|
|
38
|
+
alert: 'status-alert',
|
|
31
39
|
unhealthy: 'status-unhealthy',
|
|
32
40
|
unknown: 'status-unknown',
|
|
33
41
|
neutral: 'status-neutral',
|
|
@@ -904,7 +912,7 @@ export function getNodeTaints(node: any): { count: number; text: string } {
|
|
|
904
912
|
const taints = node.spec?.taints || []
|
|
905
913
|
const count = taints.length
|
|
906
914
|
if (count === 0) return { count: 0, text: 'None' }
|
|
907
|
-
return { count, text: count
|
|
915
|
+
return { count, text: pluralize(count, 'taint') }
|
|
908
916
|
}
|
|
909
917
|
|
|
910
918
|
export function getNodeVersion(node: any): string {
|
|
@@ -22,6 +22,7 @@ import { getOperationColor, getHealthBadgeColor, SEVERITY_BADGE } from '../../ut
|
|
|
22
22
|
import { ResourceRefBadge } from '../ui/drawer-components'
|
|
23
23
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
24
24
|
import { kindToPlural, refToSelectedResource } from '../../utils/navigation'
|
|
25
|
+
import { pluralize } from '../../utils/pluralize'
|
|
25
26
|
import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
|
|
26
27
|
|
|
27
28
|
/** Format resource age (e.g., "3d", "5h", "10m") */
|
|
@@ -436,7 +437,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
436
437
|
<Clock className="w-4 h-4 text-theme-text-tertiary" />
|
|
437
438
|
<span className="text-sm font-medium text-theme-text-secondary">{group.label}</span>
|
|
438
439
|
<span className="text-xs text-theme-text-disabled">
|
|
439
|
-
({group.items.length
|
|
440
|
+
({pluralize(group.items.length, 'item')})
|
|
440
441
|
</span>
|
|
441
442
|
</div>
|
|
442
443
|
|
|
@@ -5,6 +5,7 @@ import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
|
|
|
5
5
|
import type { HealthStatus } from '../../types'
|
|
6
6
|
import { Tooltip } from '../ui/Tooltip'
|
|
7
7
|
import type { WorkloadCard, GroupDisplayLevel } from './layout'
|
|
8
|
+
import { pluralize } from '../../utils/pluralize'
|
|
8
9
|
|
|
9
10
|
interface GroupNodeData {
|
|
10
11
|
type: 'namespace' | 'app' | 'label'
|
|
@@ -195,7 +196,7 @@ export const GroupNode = memo(function GroupNode({
|
|
|
195
196
|
{kindPills.map(([kind, count]) => (
|
|
196
197
|
<div key={kind} className="flex items-center gap-1 bg-theme-surface/50 rounded px-1.5 py-0.5">
|
|
197
198
|
<span className={`topology-icon topology-icon-${kind.toLowerCase()}`} style={{ width: 10, height: 10, fontSize: 6, borderRadius: 2 }} />
|
|
198
|
-
<span className="text-[10px] text-theme-text-secondary">{count
|
|
199
|
+
<span className="text-[10px] text-theme-text-secondary">{pluralize(count, kind)}</span>
|
|
199
200
|
</div>
|
|
200
201
|
))}
|
|
201
202
|
{kindCounts && Object.keys(kindCounts).length > maxPills && (
|
|
@@ -29,6 +29,7 @@ import { K8sResourceNode } from './K8sResourceNode'
|
|
|
29
29
|
import { GroupNode } from './GroupNode'
|
|
30
30
|
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
|
|
31
31
|
import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
|
|
32
|
+
import { pluralize } from '../../utils/pluralize'
|
|
32
33
|
|
|
33
34
|
// Edge colors by type
|
|
34
35
|
const EDGE_COLORS = {
|
|
@@ -742,7 +743,7 @@ export function TopologyGraph({
|
|
|
742
743
|
</span>
|
|
743
744
|
<span className="text-theme-text-secondary ml-1">
|
|
744
745
|
{isAllRbac
|
|
745
|
-
? `${rbacWarnings.length
|
|
746
|
+
? `${pluralize(rbacWarnings.length, 'resource type')} not accessible due to RBAC restrictions.`
|
|
746
747
|
: 'Some resources failed to load. Data may be incomplete.'}
|
|
747
748
|
</span>
|
|
748
749
|
<details className="mt-1">
|
|
@@ -6,7 +6,7 @@ import type { ReactNode } from 'react'
|
|
|
6
6
|
// All class names are STATIC STRINGS (required for Tailwind content scanning)
|
|
7
7
|
// =============================================================================
|
|
8
8
|
|
|
9
|
-
export type BadgeSeverity = 'success' | 'warning' | 'error' | 'info' | 'neutral'
|
|
9
|
+
export type BadgeSeverity = 'success' | 'warning' | 'alert' | 'error' | 'info' | 'neutral'
|
|
10
10
|
export type BadgeSize = 'sm' | 'default'
|
|
11
11
|
|
|
12
12
|
interface BadgeProps {
|
|
@@ -33,6 +33,7 @@ interface BadgeProps {
|
|
|
33
33
|
const SEVERITY: Record<BadgeSeverity, string> = {
|
|
34
34
|
success: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
|
|
35
35
|
warning: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
|
|
36
|
+
alert: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-400 dark:border-orange-700/40',
|
|
36
37
|
error: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/50 dark:text-red-400 dark:border-red-700/40',
|
|
37
38
|
info: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
|
|
38
39
|
neutral: 'bg-theme-hover/50 text-theme-text-secondary border-theme-border',
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Tooltip } from './Tooltip'
|
|
2
|
+
import { parseContextName } from '../../utils/context-name'
|
|
3
|
+
import type { ParsedContextName } from '../../utils/context-name'
|
|
4
|
+
import awsLogo from './provider-logos/aws.png'
|
|
5
|
+
import awsLogoDark from './provider-logos/aws-dark.png'
|
|
6
|
+
import gcpLogo from './provider-logos/gcp.png'
|
|
7
|
+
import azureLogo from './provider-logos/azure.svg'
|
|
8
|
+
|
|
9
|
+
// ClusterName renders a kubectl context string with the meaningful
|
|
10
|
+
// cluster identity surfaced as primary text and provider/region pushed
|
|
11
|
+
// into supporting metadata. Wraps parseContextName from utils/context-name
|
|
12
|
+
// so all surfaces (cluster cards, table cells, column headers, switcher
|
|
13
|
+
// dropdowns, breadcrumb, error views) share identical cluster-identity
|
|
14
|
+
// rendering.
|
|
15
|
+
//
|
|
16
|
+
// Variants:
|
|
17
|
+
// inline — name + small provider logo, fits in a table cell or
|
|
18
|
+
// column header
|
|
19
|
+
// stacked — name on top, provider/region on a smaller second line,
|
|
20
|
+
// for card-sized surfaces
|
|
21
|
+
//
|
|
22
|
+
// User-named clusters that don't match a known shape pass through
|
|
23
|
+
// unchanged — no provider badge, no tooltip needed.
|
|
24
|
+
|
|
25
|
+
type Provider = NonNullable<ParsedContextName['provider']>
|
|
26
|
+
|
|
27
|
+
// AWS uses the official aws+smile mark, which has dark navy text — needs
|
|
28
|
+
// a white-text variant on dark backgrounds. GCP (4-color cloud) and
|
|
29
|
+
// Azure (blue prism A) read fine on either theme.
|
|
30
|
+
const PROVIDER_LOGOS: Record<Provider, { light: string; dark?: string }> = {
|
|
31
|
+
GKE: { light: gcpLogo },
|
|
32
|
+
EKS: { light: awsLogo, dark: awsLogoDark },
|
|
33
|
+
AKS: { light: azureLogo },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Props {
|
|
37
|
+
/** Raw context / display string, as stored in the cluster record. */
|
|
38
|
+
name: string
|
|
39
|
+
/** Visual shape. Default: inline. */
|
|
40
|
+
variant?: 'inline' | 'stacked'
|
|
41
|
+
/** Suppress the provider badge — use when context already conveys provider. */
|
|
42
|
+
noBadge?: boolean
|
|
43
|
+
/** Optional className on the outer span. */
|
|
44
|
+
className?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ProviderBadge({ provider }: { provider: Provider }) {
|
|
48
|
+
const logos = PROVIDER_LOGOS[provider]
|
|
49
|
+
// object-contain keeps the AWS aws+smile mark from being warped when
|
|
50
|
+
// forced into a square box. GCP and Azure are square already.
|
|
51
|
+
const baseClass = 'h-4 w-4 flex-shrink-0 object-contain'
|
|
52
|
+
if (!logos.dark) {
|
|
53
|
+
return <img src={logos.light} alt={`${provider} cluster`} className={baseClass} />
|
|
54
|
+
}
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<img src={logos.light} alt={`${provider} cluster`} className={`${baseClass} dark:hidden`} />
|
|
58
|
+
<img src={logos.dark} alt={`${provider} cluster`} className={`${baseClass} hidden dark:block`} />
|
|
59
|
+
</>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ClusterName({ name, variant = 'inline', noBadge, className }: Props) {
|
|
64
|
+
const parsed = parseContextName(name)
|
|
65
|
+
|
|
66
|
+
const showBadge = !noBadge && parsed.provider !== null
|
|
67
|
+
const showRegion = parsed.region !== null && variant === 'stacked'
|
|
68
|
+
const needsTooltip = parsed.raw !== parsed.clusterName
|
|
69
|
+
|
|
70
|
+
const body = (
|
|
71
|
+
<span className={['inline-flex items-center gap-1.5 min-w-0', className ?? ''].join(' ')}>
|
|
72
|
+
{showBadge && <ProviderBadge provider={parsed.provider!} />}
|
|
73
|
+
{variant === 'stacked' ? (
|
|
74
|
+
<span className="flex flex-col min-w-0">
|
|
75
|
+
<span className="truncate">{parsed.clusterName}</span>
|
|
76
|
+
{showRegion && (
|
|
77
|
+
<span className="text-[10px] text-theme-text-tertiary truncate">
|
|
78
|
+
{parsed.provider} · {parsed.region}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
</span>
|
|
82
|
+
) : (
|
|
83
|
+
<span className="truncate">{parsed.clusterName}</span>
|
|
84
|
+
)}
|
|
85
|
+
</span>
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if (!needsTooltip) return body
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Tooltip content={parsed.raw} delay={250}>
|
|
92
|
+
{body}
|
|
93
|
+
</Tooltip>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { LucideIcon } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
|
|
5
|
+
// EmptyState is the shared component for the three distinct kinds of
|
|
6
|
+
// "nothing here" UI:
|
|
7
|
+
//
|
|
8
|
+
// tone='healthy' — emerald, reassuring. "All checks passing".
|
|
9
|
+
// Communicates "things are good" not "no data".
|
|
10
|
+
// tone='filtered' — neutral. "No findings match the current filters."
|
|
11
|
+
// Communicates "your filter is too narrow", suggests
|
|
12
|
+
// widening.
|
|
13
|
+
// tone='neutral' — pre-data / setup. "No clusters connected yet" with
|
|
14
|
+
// an action CTA.
|
|
15
|
+
//
|
|
16
|
+
// Variants:
|
|
17
|
+
// variant='card' — centered icon + headline + body in a bordered
|
|
18
|
+
// box. Use when an entire panel/page section is
|
|
19
|
+
// empty.
|
|
20
|
+
// variant='inline' — single-line callout. Use when one tile in a row
|
|
21
|
+
// is "empty" and you want to collapse zeros into
|
|
22
|
+
// one positive line instead.
|
|
23
|
+
|
|
24
|
+
export type EmptyStateTone = 'healthy' | 'filtered' | 'neutral'
|
|
25
|
+
export type EmptyStateVariant = 'card' | 'inline'
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
tone?: EmptyStateTone
|
|
29
|
+
variant?: EmptyStateVariant
|
|
30
|
+
/** Optional Lucide icon. When omitted: the inline variant falls back
|
|
31
|
+
* to a small colored dot (emerald for healthy, tertiary text for the
|
|
32
|
+
* rest) so the line keeps a visual anchor; the card variant renders
|
|
33
|
+
* no leading visual. Callers pass icons that match their context
|
|
34
|
+
* (e.g. CheckCircle2 for a healthy state). */
|
|
35
|
+
icon?: LucideIcon
|
|
36
|
+
headline: string
|
|
37
|
+
body?: ReactNode
|
|
38
|
+
/** Optional CTA — usually an <a> or <button>. Card variant only. */
|
|
39
|
+
action?: ReactNode
|
|
40
|
+
className?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TONE_CLASSES: Record<EmptyStateTone, { card: string; inline: string; icon: string }> = {
|
|
44
|
+
healthy: {
|
|
45
|
+
card: 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200',
|
|
46
|
+
inline: 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200',
|
|
47
|
+
icon: 'text-emerald-600 dark:text-emerald-400',
|
|
48
|
+
},
|
|
49
|
+
filtered: {
|
|
50
|
+
card: 'border-theme-border bg-theme-surface text-theme-text-secondary',
|
|
51
|
+
inline: 'border-theme-border bg-theme-surface text-theme-text-secondary',
|
|
52
|
+
icon: 'text-theme-text-tertiary',
|
|
53
|
+
},
|
|
54
|
+
neutral: {
|
|
55
|
+
card: 'border-theme-border bg-theme-surface text-theme-text-secondary',
|
|
56
|
+
inline: 'border-theme-border bg-theme-surface text-theme-text-secondary',
|
|
57
|
+
icon: 'text-theme-text-tertiary',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function EmptyState({
|
|
62
|
+
tone = 'neutral',
|
|
63
|
+
variant = 'card',
|
|
64
|
+
icon: Icon,
|
|
65
|
+
headline,
|
|
66
|
+
body,
|
|
67
|
+
action,
|
|
68
|
+
className,
|
|
69
|
+
}: Props) {
|
|
70
|
+
const t = TONE_CLASSES[tone]
|
|
71
|
+
|
|
72
|
+
if (variant === 'inline') {
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={clsx(
|
|
76
|
+
'flex flex-1 min-w-[200px] items-center gap-2 rounded-md border px-3 py-2 text-sm',
|
|
77
|
+
t.inline,
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{Icon ? (
|
|
82
|
+
<Icon className={clsx('h-4 w-4 flex-shrink-0', t.icon)} aria-hidden />
|
|
83
|
+
) : (
|
|
84
|
+
// Default to a small dot for the inline variant — keeps a visual
|
|
85
|
+
// anchor without forcing every caller to pick an icon.
|
|
86
|
+
<span
|
|
87
|
+
className={clsx(
|
|
88
|
+
'inline-flex h-2 w-2 flex-shrink-0 rounded-full',
|
|
89
|
+
tone === 'healthy'
|
|
90
|
+
? 'bg-emerald-500 dark:bg-emerald-400'
|
|
91
|
+
: 'bg-theme-text-tertiary',
|
|
92
|
+
)}
|
|
93
|
+
aria-hidden
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
<span>
|
|
97
|
+
{headline}
|
|
98
|
+
{body && <span className="opacity-80"> · {body}</span>}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
className={clsx(
|
|
107
|
+
'flex flex-col items-center gap-2 rounded-md border px-4 py-8 text-center',
|
|
108
|
+
t.card,
|
|
109
|
+
className,
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
{Icon && <Icon className={clsx('h-6 w-6', t.icon)} aria-hidden />}
|
|
113
|
+
<div className="text-sm font-medium">{headline}</div>
|
|
114
|
+
{body && <div className="max-w-md text-xs opacity-80">{body}</div>}
|
|
115
|
+
{action && <div className="mt-2">{action}</div>}
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { LucideIcon } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { Tooltip } from './Tooltip'
|
|
5
|
+
|
|
6
|
+
// FilterPill is a single-button toggle filter — clickable pill that
|
|
7
|
+
// communicates an active/inactive state. Used in horizontal filter rows
|
|
8
|
+
// where each pill toggles one filter on/off (no dropdown — this is the
|
|
9
|
+
// toggle pattern, not a combobox).
|
|
10
|
+
//
|
|
11
|
+
// Tone-encoded active state: when tone='danger' and active=true, the
|
|
12
|
+
// pill bg+text use rose; tone='warn' uses amber; etc. This is a
|
|
13
|
+
// filter-UI-scoped vocabulary (neutral/danger/warn/ok/brand) — distinct
|
|
14
|
+
// from the canonical HealthLevel vocabulary (healthy/degraded/alert/
|
|
15
|
+
// unhealthy/neutral/unknown), since "an active filter for danger
|
|
16
|
+
// problems" reads better as `tone='danger'` than `tone='unhealthy'`.
|
|
17
|
+
// Useful for filter rows that mix severity-bearing categories with
|
|
18
|
+
// neutral ones (Critical filters and Warning filters get visually
|
|
19
|
+
// distinct active states).
|
|
20
|
+
//
|
|
21
|
+
// Accessibility: every pill renders aria-pressed automatically, so
|
|
22
|
+
// screen readers announce pressed/unpressed correctly. Optional tooltip
|
|
23
|
+
// describes the toggle action ("Click to stop filtering by danger").
|
|
24
|
+
|
|
25
|
+
export type FilterPillTone = 'neutral' | 'danger' | 'warn' | 'ok' | 'brand'
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
label: ReactNode
|
|
29
|
+
active: boolean
|
|
30
|
+
onClick: () => void
|
|
31
|
+
/** Active-state color encoding. Default: neutral (Radar's existing style). */
|
|
32
|
+
tone?: FilterPillTone
|
|
33
|
+
/** Optional leading icon. */
|
|
34
|
+
icon?: LucideIcon
|
|
35
|
+
/** Optional count badge — renders " (N)" after label. */
|
|
36
|
+
count?: number
|
|
37
|
+
/** Tooltip explaining the toggle. Wraps button in Tooltip if set. */
|
|
38
|
+
tooltip?: string
|
|
39
|
+
/** Override the accessible name. Defaults to label + active state. */
|
|
40
|
+
'aria-label'?: string
|
|
41
|
+
className?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Always-bordered chip: same border-width in both states keeps geometry stable
|
|
45
|
+
// when toggling, and a visible inactive border is what makes pills read as
|
|
46
|
+
// pressable chips instead of plain links. Active states fill the chip and
|
|
47
|
+
// promote the border to a tone-matched ring.
|
|
48
|
+
const TONE_ACTIVE: Record<FilterPillTone, string> = {
|
|
49
|
+
neutral: 'bg-theme-text-primary/10 border-theme-text-primary/25 text-theme-text-primary',
|
|
50
|
+
danger: 'bg-red-500/15 border-red-500/40 text-red-700 dark:text-red-300',
|
|
51
|
+
warn: 'bg-amber-500/15 border-amber-500/40 text-amber-800 dark:text-amber-300',
|
|
52
|
+
ok: 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
|
|
53
|
+
brand: 'bg-[var(--color-brand-50)] border-[var(--color-radar-accent)] text-theme-text-primary dark:bg-[var(--color-brand-950)]',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const INACTIVE = 'border-theme-border-light text-theme-text-secondary hover:border-theme-border hover:text-theme-text-primary hover:bg-theme-hover/50'
|
|
57
|
+
|
|
58
|
+
export function FilterPill({
|
|
59
|
+
label,
|
|
60
|
+
active,
|
|
61
|
+
onClick,
|
|
62
|
+
tone = 'neutral',
|
|
63
|
+
icon: Icon,
|
|
64
|
+
count,
|
|
65
|
+
tooltip,
|
|
66
|
+
className,
|
|
67
|
+
...rest
|
|
68
|
+
}: Props) {
|
|
69
|
+
const ariaLabel = rest['aria-label']
|
|
70
|
+
|
|
71
|
+
const button = (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={onClick}
|
|
75
|
+
aria-pressed={active}
|
|
76
|
+
aria-label={ariaLabel}
|
|
77
|
+
className={clsx(
|
|
78
|
+
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors',
|
|
79
|
+
'focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none',
|
|
80
|
+
active ? TONE_ACTIVE[tone] : INACTIVE,
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{Icon && <Icon className="h-3.5 w-3.5" aria-hidden />}
|
|
85
|
+
<span>{label}</span>
|
|
86
|
+
{count !== undefined && (
|
|
87
|
+
<span className="text-theme-text-tertiary">({count})</span>
|
|
88
|
+
)}
|
|
89
|
+
</button>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if (!tooltip) return button
|
|
93
|
+
return (
|
|
94
|
+
<Tooltip content={tooltip} delay={200}>
|
|
95
|
+
{button}
|
|
96
|
+
</Tooltip>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
|
|
|
2
2
|
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
|
3
3
|
import { ConfirmDialog } from './ConfirmDialog'
|
|
4
4
|
import { formatKindName } from './drawer-components'
|
|
5
|
+
import { pluralize } from '../../utils/pluralize'
|
|
5
6
|
|
|
6
7
|
export interface CascadeDependent {
|
|
7
8
|
kind: string
|
|
@@ -106,7 +107,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
|
|
|
106
107
|
>
|
|
107
108
|
{expanded ? <ChevronDown className="w-3.5 h-3.5 shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 shrink-0" />}
|
|
108
109
|
<span>
|
|
109
|
-
Will also delete {dependents.length
|
|
110
|
+
Will also delete {pluralize(dependents.length, 'dependent resource')}
|
|
110
111
|
</span>
|
|
111
112
|
</button>
|
|
112
113
|
|
|
@@ -40,7 +40,7 @@ export function ResourceBar({ used, total, percent, colorScheme = 'utilization',
|
|
|
40
40
|
</span>
|
|
41
41
|
</div>
|
|
42
42
|
<div className="relative">
|
|
43
|
-
<div className="h-1.5 rounded-full border border-theme-border bg-theme-
|
|
43
|
+
<div className="h-1.5 rounded-full border border-theme-border bg-theme-elevated overflow-hidden">
|
|
44
44
|
<div
|
|
45
45
|
className={clsx('h-full rounded-full transition-[width] duration-300 ease-out', getBarColor(percent, colorScheme))}
|
|
46
46
|
style={{ width: `${Math.min(percent, 100)}%` }}
|
|
@@ -2,6 +2,21 @@ import { ReactNode, useState, useRef, useEffect, useCallback } from 'react'
|
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
|
|
5
|
+
// Module-level singleton coordinator: only one Tooltip can be visible
|
|
6
|
+
// at a time across the whole app. Without this, two Tooltip instances
|
|
7
|
+
// could both render their portal simultaneously — happens when a
|
|
8
|
+
// trigger element unmounts/remounts during an in-progress hover (React
|
|
9
|
+
// re-render, HMR, rapid cursor movement between adjacent triggers),
|
|
10
|
+
// because the old trigger's mouseleave never fires so its visible
|
|
11
|
+
// state stays stuck. Observed in multi-cluster visual tests on
|
|
12
|
+
// densely-populated source-chip hovers.
|
|
13
|
+
//
|
|
14
|
+
// Each visible Tooltip registers a `hide` callback. When the next
|
|
15
|
+
// Tooltip becomes visible, it calls the previous active tooltip's
|
|
16
|
+
// hide(), guaranteeing a single visible portal. Registry clears on
|
|
17
|
+
// hide or unmount.
|
|
18
|
+
let activeHide: (() => void) | null = null
|
|
19
|
+
|
|
5
20
|
interface TooltipProps {
|
|
6
21
|
content: ReactNode
|
|
7
22
|
children: ReactNode
|
|
@@ -79,9 +94,27 @@ export function Tooltip({
|
|
|
79
94
|
setCoords({ top, left })
|
|
80
95
|
}, [position])
|
|
81
96
|
|
|
97
|
+
// Stable hide function for the singleton registry — useRef so the
|
|
98
|
+
// identity stays the same across renders, otherwise the registry
|
|
99
|
+
// could hold a stale closure that doesn't see the latest setState.
|
|
100
|
+
const hideRef = useRef<() => void>(() => {})
|
|
101
|
+
hideRef.current = () => {
|
|
102
|
+
if (timeoutRef.current) {
|
|
103
|
+
clearTimeout(timeoutRef.current)
|
|
104
|
+
timeoutRef.current = null
|
|
105
|
+
}
|
|
106
|
+
setIsVisible(false)
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
const showTooltip = () => {
|
|
83
110
|
if (disabled || !content) return
|
|
84
111
|
timeoutRef.current = window.setTimeout(() => {
|
|
112
|
+
// Singleton: hide whoever was visible before us, register self
|
|
113
|
+
// as the new active tooltip. Guards against stuck duplicates.
|
|
114
|
+
if (activeHide && activeHide !== hideRef.current) {
|
|
115
|
+
activeHide()
|
|
116
|
+
}
|
|
117
|
+
activeHide = hideRef.current
|
|
85
118
|
setIsVisible(true)
|
|
86
119
|
}, delay)
|
|
87
120
|
}
|
|
@@ -91,6 +124,9 @@ export function Tooltip({
|
|
|
91
124
|
clearTimeout(timeoutRef.current)
|
|
92
125
|
timeoutRef.current = null
|
|
93
126
|
}
|
|
127
|
+
if (activeHide === hideRef.current) {
|
|
128
|
+
activeHide = null
|
|
129
|
+
}
|
|
94
130
|
setIsVisible(false)
|
|
95
131
|
}
|
|
96
132
|
|
|
@@ -106,6 +142,13 @@ export function Tooltip({
|
|
|
106
142
|
if (timeoutRef.current) {
|
|
107
143
|
clearTimeout(timeoutRef.current)
|
|
108
144
|
}
|
|
145
|
+
// Clear from singleton registry on unmount — otherwise a Tooltip
|
|
146
|
+
// that unmounts while visible (e.g. row removed during hover)
|
|
147
|
+
// would leave a stale entry in activeHide pointing at a torn-down
|
|
148
|
+
// setState, blocking the next Tooltip from registering.
|
|
149
|
+
if (activeHide === hideRef.current) {
|
|
150
|
+
activeHide = null
|
|
151
|
+
}
|
|
109
152
|
}
|
|
110
153
|
}, [])
|
|
111
154
|
|