@skyhook-io/k8s-ui 1.14.4 → 1.14.5

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.
@@ -6,6 +6,7 @@ export {
6
6
  WORKLOAD_METRIC_CATEGORIES,
7
7
  NODE_METRIC_CATEGORIES,
8
8
  METRIC_TIME_RANGES,
9
+ describePodCoverage,
9
10
  } from './PrometheusChartsView'
10
11
  export type {
11
12
  PrometheusChartsViewProps,
@@ -14,13 +15,14 @@ export type {
14
15
  MetricCategoryDef,
15
16
  PrometheusResourceMetricsResult,
16
17
  } from './PrometheusChartsView'
17
- export { SERIES_COLORS, seriesColor, seriesFill, computeShortLabels } from './colors'
18
+ export { SERIES_COLORS, seriesColor, seriesFill, computeShortLabels, seriesDisplayLabels } from './colors'
18
19
  export { formatMetricValue, formatTimestamp } from './format'
19
20
  export { computeSaturation } from './saturation'
20
21
  export type {
21
22
  TimeSeriesPoint,
22
23
  TimeSeries,
23
24
  ReferenceLine,
25
+ ChartAnnotation,
24
26
  // Deprecated Prom-prefixed aliases — see types.ts.
25
27
  PrometheusDataPoint,
26
28
  PrometheusSeries,
@@ -35,3 +35,15 @@ export interface ReferenceLine {
35
35
  label: string
36
36
  kind: 'request' | 'limit'
37
37
  }
38
+
39
+ /**
40
+ * Vertical marker on a chart at one instant. `kind` names what Radar recorded
41
+ * there; the chart draws the marker and its label and never implies that the
42
+ * marked instant explains the series around it.
43
+ */
44
+ export interface ChartAnnotation {
45
+ /** Unix seconds, the same clock as {@link TimeSeriesPoint.timestamp}. */
46
+ timestamp: number
47
+ label: string
48
+ kind: 'change'
49
+ }
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToString } from "react-dom/server";
3
+ import {
4
+ HPADiagnosisSummary,
5
+ hpaBadgeSeverity,
6
+ hpaReasonGroups,
7
+ isHPAReasonRedundant,
8
+ } from "./HPADiagnosisSummary";
9
+ import type { HPADiagnosisState, HPADiagnosisView } from "../../types";
10
+
11
+ // React marks interpolation boundaries with empty comments; strip them so
12
+ // assertions can quote the sentence a reader actually sees.
13
+ const render = (node: Parameters<typeof renderToString>[0]) =>
14
+ renderToString(node).replace(/<!-- -->/g, "");
15
+
16
+ const maxed: HPADiagnosisView = {
17
+ state: "limited_max",
18
+ summary: "HPA wants more replicas but is capped at maxReplicas=10",
19
+ bounds: { min: 1, max: 10, current: 10, desired: 10 },
20
+ reasons: [
21
+ {
22
+ id: "limited_max",
23
+ message: "HPA is capped at maxReplicas=10",
24
+ detail:
25
+ "the desired replica count is more than the maximum replica count",
26
+ conditionType: "ScalingLimited",
27
+ conditionReason: "TooManyReplicas",
28
+ },
29
+ {
30
+ id: "missing_current_metric",
31
+ message: "HPA is missing current metric values",
32
+ detail: "cpu",
33
+ },
34
+ ],
35
+ };
36
+
37
+ describe("hpaBadgeSeverity", () => {
38
+ const expected: Record<HPADiagnosisState, string> = {
39
+ ok: "success",
40
+ scaling_up: "warning",
41
+ scaling_down: "warning",
42
+ limited_max: "warning",
43
+ metrics_incomplete: "warning",
44
+ limited_min: "info",
45
+ scaled_to_zero: "info",
46
+ disabled: "info",
47
+ pinned: "info",
48
+ stabilized: "info",
49
+ metrics_unavailable: "error",
50
+ unable_to_scale: "error",
51
+ stale: "neutral",
52
+ unknown: "neutral",
53
+ };
54
+
55
+ it.each(Object.entries(expected))(
56
+ "maps %s to the %s badge",
57
+ (state, severity) => {
58
+ expect(hpaBadgeSeverity(state as HPADiagnosisState)).toBe(severity);
59
+ },
60
+ );
61
+ });
62
+
63
+ describe("isHPAReasonRedundant", () => {
64
+ it("treats the reason that named the state as a restatement", () => {
65
+ expect(isHPAReasonRedundant(maxed, maxed.reasons![0])).toBe(true);
66
+ });
67
+
68
+ it("treats a reason whose message repeats the summary as a restatement", () => {
69
+ const echo = { id: "other", message: maxed.summary };
70
+ expect(isHPAReasonRedundant(maxed, echo)).toBe(true);
71
+ });
72
+
73
+ it("keeps a reason that adds something the headline does not", () => {
74
+ expect(isHPAReasonRedundant(maxed, maxed.reasons![1])).toBe(false);
75
+ });
76
+
77
+ it("splits reasons into restatements and additions", () => {
78
+ const groups = hpaReasonGroups(maxed);
79
+ expect(groups.redundant.map((reason) => reason.id)).toEqual([
80
+ "limited_max",
81
+ ]);
82
+ expect(groups.additional.map((reason) => reason.id)).toEqual([
83
+ "missing_current_metric",
84
+ ]);
85
+ });
86
+ });
87
+
88
+ describe("HPADiagnosisSummary", () => {
89
+ it("shows the detail variant evidence without repeating the headline", () => {
90
+ const html = render(
91
+ <HPADiagnosisSummary diagnosis={maxed} variant="detail" />,
92
+ );
93
+
94
+ expect(html).toContain(
95
+ "HPA wants more replicas but is capped at maxReplicas=10",
96
+ );
97
+ expect(html).toContain("10/10 replicas, bounds 1-10");
98
+ expect(html).toContain("Maxed");
99
+ expect(html).toContain("Evidence");
100
+ expect(html).toContain("limited max");
101
+ expect(html).toContain("ScalingLimited");
102
+ expect(html).toContain("TooManyReplicas");
103
+ expect(html).not.toContain("HPA is capped at maxReplicas=10");
104
+ expect(html).toContain("HPA is missing current metric values");
105
+ });
106
+
107
+ // `detail` is the controller's sentence only on a condition-built reason.
108
+ // On the others it is Radar's own — a metric-name list, a note about an
109
+ // unobserved generation — and quoting it as Kubernetes invents a quote.
110
+ it("never attributes Radar's own detail to Kubernetes", () => {
111
+ const radarDetail: HPADiagnosisView = {
112
+ state: "stale_status",
113
+ summary: "HPA status has not observed the latest spec generation yet",
114
+ bounds: { min: 1, max: 10, current: 3, desired: 3 },
115
+ reasons: [
116
+ {
117
+ id: "stale_status",
118
+ message: "HPA status has not observed the latest spec generation yet",
119
+ detail: "the controller has recorded no status for generation 7",
120
+ },
121
+ {
122
+ id: "missing_current_metric",
123
+ message:
124
+ "HPA status is missing current values for one or more configured metrics",
125
+ detail: "cpu, memory",
126
+ },
127
+ ],
128
+ } as unknown as HPADiagnosisView;
129
+
130
+ for (const variant of ["detail", "inline"] as const) {
131
+ const html = render(
132
+ <HPADiagnosisSummary diagnosis={radarDetail} variant={variant} />,
133
+ );
134
+ expect(html, variant).toContain(
135
+ "the controller has recorded no status for generation 7",
136
+ );
137
+ expect(html, variant).toContain("cpu, memory");
138
+ expect(html, variant).not.toContain("Kubernetes");
139
+ }
140
+ });
141
+
142
+ it("attributes the controller condition in the inline variant and lists the rest", () => {
143
+ const html = render(
144
+ <HPADiagnosisSummary
145
+ diagnosis={maxed}
146
+ variant="inline"
147
+ header={
148
+ <span data-testid="host-header">HorizontalPodAutoscaler api-hpa</span>
149
+ }
150
+ />,
151
+ );
152
+
153
+ expect(html).toContain("HorizontalPodAutoscaler api-hpa");
154
+ expect(html).toContain("Maxed");
155
+ expect(html).toContain("10/10 replicas · bounds 1-10");
156
+ expect(html).toContain(
157
+ "HPA wants more replicas but is capped at maxReplicas=10",
158
+ );
159
+ expect(html).not.toContain("HPA is capped at maxReplicas=10");
160
+ expect(html).toContain(
161
+ "Kubernetes ScalingLimited · TooManyReplicas: “the desired replica count is more than the maximum replica count”",
162
+ );
163
+ expect(html).toContain("HPA is missing current metric values");
164
+ expect(html).toContain("· cpu");
165
+ });
166
+
167
+ it("omits the bounds line when the producer withheld bounds", () => {
168
+ const html = render(
169
+ <HPADiagnosisSummary
170
+ diagnosis={{ state: "ok", summary: "HPA is tracking its target" }}
171
+ variant="inline"
172
+ />,
173
+ );
174
+
175
+ expect(html).toContain("HPA is tracking its target");
176
+ expect(html).not.toContain("replicas");
177
+ });
178
+ });
@@ -0,0 +1,218 @@
1
+ import type { ReactNode } from "react";
2
+ import { Badge, type BadgeSeverity } from "../ui/Badge";
3
+ import { StatusDot } from "../ui/status-tone";
4
+ import { hpaStateLabel, hpaStateLevel } from "./resource-utils-hpa";
5
+ import type {
6
+ HPADiagnosisState,
7
+ HPADiagnosisView,
8
+ HPAReasonSummary,
9
+ } from "../../types";
10
+
11
+ export function hpaBadgeSeverity(state: HPADiagnosisState): BadgeSeverity {
12
+ switch (hpaStateLevel(state)) {
13
+ case "healthy":
14
+ return "success";
15
+ case "unhealthy":
16
+ return "error";
17
+ case "degraded":
18
+ return "warning";
19
+ case "alert":
20
+ return "alert";
21
+ case "neutral":
22
+ return "info";
23
+ default:
24
+ return "neutral";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * A reason that named the state, or repeats the summary verbatim, says the
30
+ * headline over again — only the controller's own sentence behind it (the
31
+ * reason's detail) adds anything a reader does not already have.
32
+ */
33
+ export function isHPAReasonRedundant(
34
+ diagnosis: HPADiagnosisView,
35
+ reason: HPAReasonSummary,
36
+ ): boolean {
37
+ return reason.id === diagnosis.state || reason.message === diagnosis.summary;
38
+ }
39
+
40
+ export interface HPAReasonGroups {
41
+ /** Reasons whose message restates the headline; render their detail, not their message. */
42
+ redundant: HPAReasonSummary[];
43
+ /** Reasons that carry something the headline does not. */
44
+ additional: HPAReasonSummary[];
45
+ }
46
+
47
+ export function hpaReasonGroups(diagnosis: HPADiagnosisView): HPAReasonGroups {
48
+ const reasons = diagnosis.reasons ?? [];
49
+ const redundant = reasons.filter((reason) =>
50
+ isHPAReasonRedundant(diagnosis, reason),
51
+ );
52
+ return {
53
+ redundant,
54
+ additional: reasons.filter((reason) => !redundant.includes(reason)),
55
+ };
56
+ }
57
+
58
+ function formatReasonID(id: string): string {
59
+ return id.replace(/_/g, " ");
60
+ }
61
+
62
+ export interface HPADiagnosisSummaryProps {
63
+ diagnosis: HPADiagnosisView;
64
+ /**
65
+ * `detail` — the HPA detail page: a boxed Evidence block per reason.
66
+ * `inline` — an evidence card on the workload the HPA scales: the headline
67
+ * once, then the controller's condition quoted and attributed.
68
+ */
69
+ variant: "detail" | "inline";
70
+ /**
71
+ * Host-rendered identity for the `inline` variant (the scaler's name, owner
72
+ * links) placed before the state indicator. Navigation stays with the host.
73
+ */
74
+ header?: ReactNode;
75
+ }
76
+
77
+ export function HPADiagnosisSummary({
78
+ diagnosis,
79
+ variant,
80
+ header,
81
+ }: HPADiagnosisSummaryProps) {
82
+ const { redundant, additional } = hpaReasonGroups(diagnosis);
83
+ const bounds = diagnosis.bounds;
84
+
85
+ if (variant === "inline") {
86
+ return (
87
+ <div className="space-y-1 text-xs">
88
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
89
+ {header}
90
+ <span className="inline-flex items-center gap-1.5 text-theme-text-secondary">
91
+ <StatusDot
92
+ tone={hpaStateLevel(diagnosis.state)}
93
+ className="shrink-0"
94
+ />
95
+ {hpaStateLabel(diagnosis.state)}
96
+ </span>
97
+ {bounds ? (
98
+ <span className="font-mono tabular-nums text-theme-text-tertiary">
99
+ {bounds.current}/{bounds.desired} replicas · bounds {bounds.min}-
100
+ {bounds.max}
101
+ </span>
102
+ ) : null}
103
+ </div>
104
+ <p className="text-theme-text-secondary">{diagnosis.summary}</p>
105
+ {redundant.map((reason) =>
106
+ reason.detail ? (
107
+ <p
108
+ key={`${reason.id}-${reason.detail}`}
109
+ className="text-theme-text-tertiary"
110
+ >
111
+ {/* Same rule as the detail variant: only a condition-built
112
+ reason carries the controller's own sentence. */}
113
+ {reason.conditionType ? (
114
+ <>
115
+ Kubernetes {reason.conditionType}
116
+ {reason.conditionReason ? ` · ${reason.conditionReason}` : ""}
117
+ : &ldquo;{reason.detail}&rdquo;
118
+ </>
119
+ ) : (
120
+ reason.detail
121
+ )}
122
+ </p>
123
+ ) : null,
124
+ )}
125
+ {additional.length > 0 ? (
126
+ <ul className="list-disc space-y-0.5 pl-4 text-theme-text-secondary marker:text-theme-text-tertiary">
127
+ {additional.map((reason) => (
128
+ <li key={`${reason.id}-${reason.message}`}>
129
+ {reason.message}
130
+ {reason.detail ? (
131
+ <span className="text-theme-text-tertiary">
132
+ {" "}
133
+ · {reason.detail}
134
+ </span>
135
+ ) : null}
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ ) : null}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ const reasons = diagnosis.reasons ?? [];
145
+ return (
146
+ <div className="card-inner space-y-3">
147
+ <div className="flex flex-wrap items-start justify-between gap-2">
148
+ <div className="min-w-0">
149
+ <div className="text-sm font-medium text-theme-text-primary">
150
+ {diagnosis.summary}
151
+ </div>
152
+ {bounds ? (
153
+ <div className="mt-1 text-xs text-theme-text-secondary">
154
+ {bounds.current}/{bounds.desired} replicas, bounds {bounds.min}-
155
+ {bounds.max}
156
+ </div>
157
+ ) : null}
158
+ </div>
159
+ <Badge severity={hpaBadgeSeverity(diagnosis.state)}>
160
+ {hpaStateLabel(diagnosis.state)}
161
+ </Badge>
162
+ </div>
163
+ {reasons.length > 0 && (
164
+ <div className="space-y-2">
165
+ {reasons.map((reason) => (
166
+ <div
167
+ key={`${reason.id}-${reason.message}`}
168
+ className="rounded border border-theme-border bg-theme-surface p-2"
169
+ >
170
+ <div className="flex flex-wrap items-center gap-2 text-xs">
171
+ <span className="font-medium text-theme-text-primary">
172
+ Evidence
173
+ </span>
174
+ <span className="text-theme-text-tertiary">
175
+ {formatReasonID(reason.id)}
176
+ </span>
177
+ {reason.conditionType && (
178
+ <span className="text-theme-text-tertiary">
179
+ {reason.conditionType}
180
+ </span>
181
+ )}
182
+ {reason.conditionReason && (
183
+ <span className="text-theme-text-tertiary">
184
+ {reason.conditionReason}
185
+ </span>
186
+ )}
187
+ </div>
188
+ {/* Radar's reading leads, as the reason is built to; the
189
+ controller's own sentence follows it, quoted and attributed
190
+ the same way the inline variant attributes it. Unlabelled
191
+ beside the condition chips, it read as Radar's words. */}
192
+ {!isHPAReasonRedundant(diagnosis, reason) && (
193
+ <div className="mt-1 text-xs text-theme-text-secondary">
194
+ {reason.message}
195
+ </div>
196
+ )}
197
+ {reason.detail &&
198
+ // `detail` is the controller's own sentence only on a reason
199
+ // built from a condition, which is the only kind that carries
200
+ // a condition type. Elsewhere it is Radar's — a list of metric
201
+ // names, a note about an unobserved generation — and quoting
202
+ // that as Kubernetes would put words in its mouth.
203
+ (reason.conditionType ? (
204
+ <p className="mt-1 text-xs text-theme-text-tertiary">
205
+ Kubernetes: &ldquo;{reason.detail}&rdquo;
206
+ </p>
207
+ ) : (
208
+ <p className="mt-1 text-xs text-theme-text-tertiary">
209
+ {reason.detail}
210
+ </p>
211
+ ))}
212
+ </div>
213
+ ))}
214
+ </div>
215
+ )}
216
+ </div>
217
+ );
218
+ }
@@ -1,5 +1,12 @@
1
1
  export * from './resource-utils'
2
2
  export * from './resource-utils-hpa'
3
+ export {
4
+ HPADiagnosisSummary,
5
+ hpaBadgeSeverity,
6
+ hpaReasonGroups,
7
+ isHPAReasonRedundant,
8
+ } from './HPADiagnosisSummary'
9
+ export type { HPADiagnosisSummaryProps, HPAReasonGroups } from './HPADiagnosisSummary'
3
10
  export * from './resource-utils-argo'
4
11
  export * from './resource-utils-certmanager'
5
12
  export * from './resource-utils-cnpg'
@@ -2,11 +2,11 @@ import type { ReactNode } from 'react'
2
2
  import { Activity, Cpu } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, ConditionsSection, ResourceLink, type ConditionTone } from '../../ui/drawer-components'
5
- import { Badge, type BadgeSeverity } from '../../ui/Badge'
5
+ import { Badge } from '../../ui/Badge'
6
6
  import { kindToPlural } from '../../../utils/navigation'
7
7
  import { formatAge } from '../resource-utils'
8
- import { hpaStateLabel, hpaStateLevel } from '../resource-utils-hpa'
9
- import type { HPADiagnosis, HPADiagnosisState } from '../../../types'
8
+ import { HPADiagnosisSummary } from '../HPADiagnosisSummary'
9
+ import type { HPADiagnosis } from '../../../types'
10
10
 
11
11
  interface HPARendererProps {
12
12
  data: any
@@ -16,23 +16,6 @@ interface HPARendererProps {
16
16
  extraSections?: ReactNode
17
17
  }
18
18
 
19
- function hpaBadgeSeverity(state: HPADiagnosisState): BadgeSeverity {
20
- switch (hpaStateLevel(state)) {
21
- case 'healthy':
22
- return 'success'
23
- case 'unhealthy':
24
- return 'error'
25
- case 'degraded':
26
- return 'warning'
27
- case 'alert':
28
- return 'alert'
29
- case 'neutral':
30
- return 'info'
31
- default:
32
- return 'neutral'
33
- }
34
- }
35
-
36
19
  function hpaConditionTone(condition: any): ConditionTone | undefined {
37
20
  if (condition?.type === 'ScaledToZero') {
38
21
  if (condition.status === 'False') return 'ok'
@@ -50,14 +33,6 @@ function hpaConditionTone(condition: any): ConditionTone | undefined {
50
33
  return 'warning'
51
34
  }
52
35
 
53
- function formatReasonID(id: string): string {
54
- return id.replace(/_/g, ' ')
55
- }
56
-
57
- function isReasonMessageRedundant(state: HPADiagnosisState, reason: NonNullable<HPADiagnosis['reasons']>[number]): boolean {
58
- return state === 'limited_max' && reason.id === 'limited_max'
59
- }
60
-
61
36
  export function HPARenderer({ data, onNavigate, hpaDiagnosis, extraSections }: HPARendererProps) {
62
37
  const status = data.status || {}
63
38
  const spec = data.spec || {}
@@ -67,35 +42,7 @@ export function HPARenderer({ data, onNavigate, hpaDiagnosis, extraSections }: H
67
42
  <>
68
43
  {hpaDiagnosis && (
69
44
  <Section title="Diagnosis" icon={Activity}>
70
- <div className="card-inner space-y-3">
71
- <div className="flex flex-wrap items-start justify-between gap-2">
72
- <div className="min-w-0">
73
- <div className="text-sm font-medium text-theme-text-primary">{hpaDiagnosis.summary}</div>
74
- <div className="mt-1 text-xs text-theme-text-secondary">
75
- {hpaDiagnosis.bounds.current}/{hpaDiagnosis.bounds.desired} replicas, bounds {hpaDiagnosis.bounds.min}-{hpaDiagnosis.bounds.max}
76
- </div>
77
- </div>
78
- <Badge severity={hpaBadgeSeverity(hpaDiagnosis.state)}>{hpaStateLabel(hpaDiagnosis.state)}</Badge>
79
- </div>
80
- {hpaDiagnosis.reasons && hpaDiagnosis.reasons.length > 0 && (
81
- <div className="space-y-2">
82
- {hpaDiagnosis.reasons.map((reason) => (
83
- <div key={`${reason.id}-${reason.message}`} className="rounded border border-theme-border bg-theme-surface p-2">
84
- <div className="flex flex-wrap items-center gap-2 text-xs">
85
- <span className="font-medium text-theme-text-primary">Evidence</span>
86
- <span className="text-theme-text-tertiary">{formatReasonID(reason.id)}</span>
87
- {reason.conditionType && <span className="text-theme-text-tertiary">{reason.conditionType}</span>}
88
- {reason.conditionReason && <span className="text-theme-text-tertiary">{reason.conditionReason}</span>}
89
- {reason.detail && <span className="text-theme-text-tertiary">{reason.detail}</span>}
90
- </div>
91
- {!isReasonMessageRedundant(hpaDiagnosis.state, reason) && (
92
- <div className="mt-1 text-xs text-theme-text-secondary">{reason.message}</div>
93
- )}
94
- </div>
95
- ))}
96
- </div>
97
- )}
98
- </div>
45
+ <HPADiagnosisSummary diagnosis={hpaDiagnosis} variant="detail" />
99
46
  </Section>
100
47
  )}
101
48
 
@@ -6,11 +6,12 @@ import type { PolicyResourceResponse } from '../../../types/policy'
6
6
  import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge, useOperationalIssuesShown } from '../../ui/drawer-components'
7
7
  import { DialogPortal } from '../../ui/DialogPortal'
8
8
  import { Tooltip } from '../../ui/Tooltip'
9
- import { Badge, type BadgeSeverity } from '../../ui/Badge'
9
+ import { Badge } from '../../ui/Badge'
10
10
  import type { RBACSubjectResponse, RBACPolicyRule, ResourceRef, HPADiagnosis, WorkloadPodInfo } from '../../../types'
11
11
  import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
12
12
  import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
13
- import { hpaStateLabel, hpaStateLevel } from '../resource-utils-hpa'
13
+ import { hpaStateLabel } from '../resource-utils-hpa'
14
+ import { hpaBadgeSeverity } from '../HPADiagnosisSummary'
14
15
  import {
15
16
  rbacVerbBadgeClass,
16
17
  rbacResourceBadgeClass,
@@ -407,29 +408,12 @@ function ScalerDiagnosisRow({ entry }: { entry: ScalerDiagnosis }) {
407
408
  if (!entry.diagnosis) return null
408
409
  return (
409
410
  <div className="rounded border border-theme-border bg-theme-surface px-2 py-1.5 text-xs leading-6 text-theme-text-secondary whitespace-normal break-normal">
410
- <Badge severity={badgeSeverityForHPA(entry.diagnosis)} size="sm" className="mr-1.5 align-middle">{hpaStateLabel(entry.diagnosis.state)}</Badge>
411
+ <Badge severity={hpaBadgeSeverity(entry.diagnosis.state)} size="sm" className="mr-1.5 align-middle">{hpaStateLabel(entry.diagnosis.state)}</Badge>
411
412
  <span className="align-middle whitespace-normal break-normal">{compactHPASummary(entry.diagnosis)}</span>
412
413
  </div>
413
414
  )
414
415
  }
415
416
 
416
- function badgeSeverityForHPA(diagnosis: HPADiagnosis): BadgeSeverity {
417
- switch (hpaStateLevel(diagnosis.state)) {
418
- case 'healthy':
419
- return 'success'
420
- case 'unhealthy':
421
- return 'error'
422
- case 'degraded':
423
- return 'warning'
424
- case 'alert':
425
- return 'alert'
426
- case 'neutral':
427
- return 'info'
428
- default:
429
- return 'neutral'
430
- }
431
- }
432
-
433
417
  // ============================================================================
434
418
  // WORKLOAD PERMISSIONS SECTION
435
419
  // ============================================================================
@@ -9,6 +9,15 @@ describe('baseSubtitle', () => {
9
9
  expect(baseSubtitle('ServiceMonitor', { endpointCount: 1 })).toBe('1 scrape endpoint')
10
10
  })
11
11
 
12
+ it('describes a Service by its first port, capping additional ports to a count', () => {
13
+ expect(baseSubtitle('Service', { type: 'ClusterIP', ports: [] })).toBe('ClusterIP')
14
+ expect(baseSubtitle('Service', { type: 'ClusterIP', ports: [{ port: 80 }] })).toBe('ClusterIP :80')
15
+ expect(baseSubtitle('Service', {
16
+ type: 'ClusterIP',
17
+ ports: [{ port: 80 }, { port: 443 }, { port: 9153 }],
18
+ })).toBe('ClusterIP :80 +2 more')
19
+ })
20
+
12
21
  it('describes a Calico staged deletion as a removal, not as selecting everything', () => {
13
22
  // A staged deletion carries no selector, because the Calico API forbids one.
14
23
  // Falling through to the empty-selector default would label the broadest