@skyhook-io/k8s-ui 1.18.1 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsTableView.tsx +13 -8
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
- package/src/components/gitops/insights/insights-helpers.ts +5 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +17 -5
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +2 -0
- package/src/components/issues/types.ts +10 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.tsx +26 -5
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.tsx +238 -66
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +14 -3
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.tsx +135 -2
- package/src/components/shared/ResourceActionsBar.tsx +22 -6
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/PageHeader.tsx +3 -2
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +27 -0
- package/src/components/ui/drawer-components.tsx +22 -42
- package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
- package/src/components/workload/WorkloadView.tsx +57 -34
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/types/core.ts +54 -1
- package/src/types/gitops-insights.ts +8 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/gitops-owner.ts +136 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +1 -0
- package/src/utils/read-actions.ts +34 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AlertTriangle } from 'lucide-react'
|
|
2
|
+
import { Disclosure } from '../ui/Disclosure'
|
|
3
|
+
|
|
4
|
+
export function issueGuidanceSummary(action?: string, primary?: string, hazard?: string) {
|
|
5
|
+
const summary = primary || action
|
|
6
|
+
if (!hazard || !summary) return summary
|
|
7
|
+
if (summary === hazard) return undefined
|
|
8
|
+
return summary.endsWith(` ${hazard}`) ? summary.slice(0, -hazard.length - 1) : summary
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function IssueGuidance({ action, primary, secondaryTitle, secondary, ordered, hazard, className }: {
|
|
12
|
+
action?: string
|
|
13
|
+
/** Replaces a supplied action; primary alone renders no action text. */
|
|
14
|
+
primary?: string
|
|
15
|
+
/** Paired with secondary; no disclosure is shown without both. */
|
|
16
|
+
secondaryTitle?: string
|
|
17
|
+
/** Paired with secondaryTitle; no disclosure is shown without both. */
|
|
18
|
+
secondary?: string[]
|
|
19
|
+
ordered?: boolean
|
|
20
|
+
hazard?: string
|
|
21
|
+
className?: string
|
|
22
|
+
}) {
|
|
23
|
+
if (!action && !hazard && !(secondaryTitle && secondary?.length)) return null
|
|
24
|
+
return (
|
|
25
|
+
<div className={className}>
|
|
26
|
+
{hazard && (
|
|
27
|
+
<div className="mb-2 flex items-start gap-2 text-warning-text">
|
|
28
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
29
|
+
<p>{hazard}</p>
|
|
30
|
+
</div>
|
|
31
|
+
)}
|
|
32
|
+
{action && <p>{primary || action}</p>}
|
|
33
|
+
{secondaryTitle && secondary && (
|
|
34
|
+
<Disclosure summary={secondaryTitle} className="mt-2" summaryClassName="font-medium">
|
|
35
|
+
{ordered ? (
|
|
36
|
+
<ol className="list-decimal space-y-2 pl-5 pt-2">
|
|
37
|
+
{secondary.map((step) => <li key={step}>{step}</li>)}
|
|
38
|
+
</ol>
|
|
39
|
+
) : (
|
|
40
|
+
<div className="space-y-2 pt-2">
|
|
41
|
+
{secondary.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</Disclosure>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { IssueGuidance, issueGuidanceSummary } from './IssueGuidance'
|
|
1
2
|
import { summarizeSchedulerMessage } from '../resources/resource-utils'
|
|
2
3
|
import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
|
3
4
|
import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, Eraser, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
|
|
4
|
-
import {
|
|
5
|
+
import { CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
|
|
5
6
|
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse';
|
|
6
7
|
import { Tooltip } from '../ui/Tooltip';
|
|
7
8
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
@@ -88,8 +89,8 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
|
|
|
88
89
|
tone="healthy"
|
|
89
90
|
variant="card"
|
|
90
91
|
icon={CircleCheck}
|
|
91
|
-
headline="
|
|
92
|
-
body="
|
|
92
|
+
headline="No issues detected"
|
|
93
|
+
body="Radar's checks found nothing broken in the selected scope. They don't cover every failure, and Audit lists configuration that can still break things."
|
|
93
94
|
/>
|
|
94
95
|
) : (
|
|
95
96
|
<EmptyState headline="No issue data yet" body="Connect a cluster to populate the issue queue." action={emptyAction} />
|
|
@@ -368,6 +369,15 @@ export function IssueRow({
|
|
|
368
369
|
</div>
|
|
369
370
|
</div>
|
|
370
371
|
|
|
372
|
+
{(issue.action_primary || issue.hazard) && (
|
|
373
|
+
<Collapse open={!open} unmountOnExit>
|
|
374
|
+
<div className="px-4 pb-3 pl-6 text-xs">
|
|
375
|
+
{issue.cause && <p className="mb-1 text-theme-text-primary">{issue.cause}</p>}
|
|
376
|
+
<IssueGuidance hazard={issue.hazard} action={issue.action_primary ? issueGuidanceSummary(issue.action, issue.action_primary, issue.hazard) : undefined} secondaryTitle={issue.action_secondary_title} secondary={issue.action_secondary} ordered={issue.reason === 'SharedResourceWarning'} className="text-theme-text-tertiary" />
|
|
377
|
+
</div>
|
|
378
|
+
</Collapse>
|
|
379
|
+
)}
|
|
380
|
+
|
|
371
381
|
{/* Details unmount after the close transition: a list can hold hundreds
|
|
372
382
|
of rows and the body is the expensive part, so closed rows stay light. */}
|
|
373
383
|
<Collapse open={open} unmountOnExit id={panelId}>
|
|
@@ -509,11 +519,13 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
509
519
|
</>
|
|
510
520
|
) : undefined;
|
|
511
521
|
|
|
522
|
+
const guidanceSummary = issueGuidanceSummary(issue.action, issue.action_primary, issue.hazard);
|
|
512
523
|
const hasNextStep =
|
|
513
|
-
!!issue.
|
|
524
|
+
!!guidanceSummary || !!issue.action_secondary?.length || !!sourceNextStep || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
|
|
514
525
|
|
|
515
526
|
return (
|
|
516
527
|
<div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
|
|
528
|
+
<IssueGuidance hazard={issue.hazard} className="text-sm leading-relaxed" />
|
|
517
529
|
{/* When a parsed cause replaces the detector text, the terse reason code
|
|
518
530
|
(CrashLoopBackOff, FailedMount…) rides the eyebrow — it's the greppable
|
|
519
531
|
identifier operators anchor on, and the collapsed header drops it while
|
|
@@ -547,7 +559,7 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
547
559
|
tone="fix"
|
|
548
560
|
labelExtra={nextStepAttribution ?? undefined}
|
|
549
561
|
>
|
|
550
|
-
{sourceNextStep ??
|
|
562
|
+
{sourceNextStep ?? <IssueGuidance action={guidanceSummary} secondaryTitle={issue.action_secondary_title} secondary={issue.action_secondary} ordered={issue.reason === 'SharedResourceWarning'} className="text-sm leading-relaxed text-theme-text-secondary" />}
|
|
551
563
|
{/* Survives an override: this is a structured one-click fix with an
|
|
552
564
|
apply path, not advice competing with the host's. */}
|
|
553
565
|
{issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
|
|
@@ -54,6 +54,8 @@ export function diagnosticFactLabel(type: string): string {
|
|
|
54
54
|
return 'Admission dependency';
|
|
55
55
|
case 'network_policy_isolates':
|
|
56
56
|
return 'Affected resources';
|
|
57
|
+
case 'tls_certificate_consumers':
|
|
58
|
+
return 'Used for TLS by';
|
|
57
59
|
default:
|
|
58
60
|
return type.replace(/_/g, ' ');
|
|
59
61
|
}
|
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
memberRef,
|
|
16
16
|
issueSortAnchor,
|
|
17
17
|
compareIssueSortAnchors,
|
|
18
|
+
compareIssues,
|
|
18
19
|
} from './types';
|
|
19
20
|
export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField, IssueRunningPreviousValue, IssueWithheld } from './types';
|
|
20
21
|
export {
|
|
@@ -100,6 +100,7 @@ const CATEGORY_LABEL: Record<string, string> = {
|
|
|
100
100
|
crossplane_reconcile_failed: 'Crossplane reconcile failed',
|
|
101
101
|
termination_stuck: 'Stuck terminating',
|
|
102
102
|
operator_condition_failed: 'Controller reports a problem',
|
|
103
|
+
gitops_resource_warning: 'GitOps resource warning',
|
|
103
104
|
gitops_sync_failed: 'GitOps sync failed',
|
|
104
105
|
gitops_render_failed: 'GitOps render failed',
|
|
105
106
|
gitops_spec_invalid: 'GitOps spec invalid',
|
|
@@ -140,6 +141,7 @@ export function categoryLabel(category: string): string {
|
|
|
140
141
|
// Reasons filed under a category whose label names a different operation:
|
|
141
142
|
// CNPG archiving and an unanswered scheduled run sit under backup_failed.
|
|
142
143
|
const REASON_TITLE: Record<string, string> = {
|
|
144
|
+
FlaggerCanaryFailed: "Canary rollout failed",
|
|
143
145
|
InvalidSchedule: 'Scheduling error reported',
|
|
144
146
|
UnparseableSchedule: 'Scheduling error reported',
|
|
145
147
|
UnParseableCronJobSchedule: 'Scheduling error reported',
|
|
@@ -231,6 +231,16 @@ export interface Issue {
|
|
|
231
231
|
* Server-emitted (omitempty); empty for issues without a parser. */
|
|
232
232
|
cause?: string;
|
|
233
233
|
action?: string;
|
|
234
|
+
/** What the suggested or most obvious action could destroy or take over,
|
|
235
|
+
* such as a GitOps sync that would prune every managed resource. Show it
|
|
236
|
+
* even when replacing cause or action with other guidance; action also
|
|
237
|
+
* contains it. Absence means no warning was supplied, not that the action
|
|
238
|
+
* is safe; older producers omit it. */
|
|
239
|
+
hazard?: string;
|
|
240
|
+
/** Optional presentation split; action always retains the complete guidance. */
|
|
241
|
+
action_primary?: string;
|
|
242
|
+
action_secondary_title?: string;
|
|
243
|
+
action_secondary?: string[];
|
|
234
244
|
remediation_kind?: string;
|
|
235
245
|
remediation_target?: string;
|
|
236
246
|
/** A cleanup that is right only when its condition holds — never part of
|
|
@@ -707,7 +707,7 @@ export function LogCore({
|
|
|
707
707
|
})}
|
|
708
708
|
</div>
|
|
709
709
|
|
|
710
|
-
<div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2">
|
|
710
|
+
<div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2 phone:gap-1">
|
|
711
711
|
|
|
712
712
|
{/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
|
|
713
713
|
{hasStructuredEntries && (
|
|
@@ -715,7 +715,7 @@ export function LogCore({
|
|
|
715
715
|
<Tooltip content={`Structured: ${STRUCTURED_MODE_LABELS[structuredMode]} — click to cycle`} delay={TIP_DELAY} position="bottom">
|
|
716
716
|
<button
|
|
717
717
|
onClick={cycleStructuredMode}
|
|
718
|
-
className={`p-1.5 rounded-l transition-colors ${
|
|
718
|
+
className={`p-1.5 rounded-l phone:rounded transition-colors ${
|
|
719
719
|
structuredMode === 'compact' ? iconBtnInactive : palette.toolbarActive
|
|
720
720
|
}`}
|
|
721
721
|
aria-label={`Structured log display mode: ${STRUCTURED_MODE_LABELS[structuredMode]}`}
|
|
@@ -723,7 +723,8 @@ export function LogCore({
|
|
|
723
723
|
<Braces className="w-4 h-4" />
|
|
724
724
|
</button>
|
|
725
725
|
</Tooltip>
|
|
726
|
-
|
|
726
|
+
{/* Phones keep the icon (it cycles the modes) and drop the picker to fit one row. */}
|
|
727
|
+
<div className="relative phone:hidden" ref={structuredMenuRef}>
|
|
727
728
|
<Tooltip content="Pick structured display mode" delay={TIP_DELAY} position="bottom">
|
|
728
729
|
<button
|
|
729
730
|
onClick={() => setShowStructuredMenu(prev => !prev)}
|
|
@@ -874,7 +875,7 @@ export function LogCore({
|
|
|
874
875
|
</Tooltip>
|
|
875
876
|
|
|
876
877
|
{!themeLocked && (
|
|
877
|
-
<Tooltip content={isDark ? 'Switch to light mode' : 'Switch to dark mode'} delay={TIP_DELAY} position="bottom">
|
|
878
|
+
<Tooltip content={isDark ? 'Switch to light mode' : 'Switch to dark mode'} delay={TIP_DELAY} position="bottom" wrapperClassName="phone:hidden">
|
|
878
879
|
<button
|
|
879
880
|
onClick={toggleDark}
|
|
880
881
|
className={iconBtnInactive}
|
|
@@ -987,8 +988,9 @@ export function LogCore({
|
|
|
987
988
|
</div>
|
|
988
989
|
|
|
989
990
|
{/* Search bar */}
|
|
991
|
+
{/* Phones: the query takes the first line and the search options wrap under it. */}
|
|
990
992
|
{search.isOpen && (
|
|
991
|
-
<div className={`@container/logsearch flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
|
|
993
|
+
<div className={`@container/logsearch flex items-center gap-2 phone:flex-wrap phone:gap-y-1.5 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
|
|
992
994
|
<Search className={`w-4 h-4 ${palette.textSecondary} shrink-0`} />
|
|
993
995
|
<Input
|
|
994
996
|
value={search.query}
|
|
@@ -1005,7 +1007,7 @@ export function LogCore({
|
|
|
1005
1007
|
}
|
|
1006
1008
|
}}
|
|
1007
1009
|
placeholder="Search logs..."
|
|
1008
|
-
className={`flex-1 bg-transparent ${palette.textPrimary} text-sm ${palette.placeholder} focus:outline-none min-w-0`}
|
|
1010
|
+
className={`flex-1 phone:basis-[calc(100%-1.5rem)] bg-transparent ${palette.textPrimary} text-sm ${palette.placeholder} focus:outline-none min-w-0`}
|
|
1009
1011
|
autoFocus
|
|
1010
1012
|
/>
|
|
1011
1013
|
|
|
@@ -1187,8 +1189,8 @@ export function LogCore({
|
|
|
1187
1189
|
</div>
|
|
1188
1190
|
)}
|
|
1189
1191
|
|
|
1190
|
-
{/* Keyboard shortcut hints */}
|
|
1191
|
-
<div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
|
|
1192
|
+
{/* Keyboard shortcut hints (a phone has no keyboard to use them) */}
|
|
1193
|
+
<div className={`flex items-center gap-4 px-3 py-1 border-t phone:hidden ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
|
|
1192
1194
|
{onStartStream && (!sourceUnavailable || isStreaming) && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
|
|
1193
1195
|
<Shortcut keys="Ctrl+F" label="Search" palette={palette} />
|
|
1194
1196
|
{search.mode !== 'hide' && (
|
|
@@ -1353,7 +1355,7 @@ function LogLine({
|
|
|
1353
1355
|
}
|
|
1354
1356
|
|
|
1355
1357
|
return (
|
|
1356
|
-
<div className={`flex ${palette.hoverSurface} group leading-5 px-2 ${isCurrentMatch ? palette.currentMatchBg : ''}`}>
|
|
1358
|
+
<div className={`flex phone:flex-wrap ${palette.hoverSurface} group leading-5 px-2 ${isCurrentMatch ? palette.currentMatchBg : ''}`}>
|
|
1357
1359
|
{leadSlot}
|
|
1358
1360
|
{showTimestamp && entry.timestamp && (
|
|
1359
1361
|
<span
|
|
@@ -1371,10 +1373,10 @@ function LogLine({
|
|
|
1371
1373
|
[{entry.sourceLabel || entry.pod.split('-').slice(-2).join('-')}]
|
|
1372
1374
|
</span>
|
|
1373
1375
|
)}
|
|
1374
|
-
<span className="flex-1 min-w-0">{contentElement}</span>
|
|
1376
|
+
<span className="flex-1 min-w-0 phone:order-last phone:basis-full">{contentElement}</span>
|
|
1375
1377
|
<button
|
|
1376
1378
|
onClick={handleCopy}
|
|
1377
|
-
className={`opacity-0 group-hover:opacity-100 focus-visible:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity`}
|
|
1379
|
+
className={`opacity-0 group-hover:opacity-100 focus-visible:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity phone:ml-auto phone:opacity-100`}
|
|
1378
1380
|
title="Copy line"
|
|
1379
1381
|
>
|
|
1380
1382
|
<Copy className="w-3 h-3" />
|
|
@@ -327,7 +327,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
327
327
|
const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
|
|
328
328
|
<>
|
|
329
329
|
{/* Pod filter */}
|
|
330
|
-
<fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50">
|
|
330
|
+
<fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50 phone:flex-wrap">
|
|
331
331
|
<div className="relative">
|
|
332
332
|
<button
|
|
333
333
|
onClick={() => setShowPodFilter(v => !v)}
|
|
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
|
|
3
3
|
import { ChevronDown, Globe, Search } from 'lucide-react'
|
|
4
4
|
import { Badge } from '../ui/Badge'
|
|
5
5
|
import { Tooltip } from '../ui/Tooltip'
|
|
6
|
+
import { useKeepInViewport } from '../../hooks/useKeepInViewport'
|
|
6
7
|
import { MultiSelectPicker } from '../ui/MultiSelectPicker'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -96,6 +97,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
96
97
|
|
|
97
98
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
98
99
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
100
|
+
useKeepInViewport(dropdownRef, isOpen)
|
|
99
101
|
const [restoreFocus, setRestoreFocus] = useState(false)
|
|
100
102
|
|
|
101
103
|
const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
|
|
@@ -40,3 +40,31 @@ it('opens a matching View with Enter when search finds no kind', () => {
|
|
|
40
40
|
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
|
|
41
41
|
expect(view).toHaveBeenCalledTimes(1)
|
|
42
42
|
})
|
|
43
|
+
it('in a drawer, Escape in a non-empty kind filter clears it without reaching the dialog', () => {
|
|
44
|
+
const outside = vi.fn()
|
|
45
|
+
document.addEventListener('keydown', outside)
|
|
46
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
47
|
+
act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} containEscape />))
|
|
48
|
+
const input = host.querySelector('input')!
|
|
49
|
+
const type = (v: string) => act(() => {
|
|
50
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, v)
|
|
51
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
52
|
+
})
|
|
53
|
+
const escape = () => act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
|
|
54
|
+
type('pod'); escape()
|
|
55
|
+
expect(input.value).toBe('')
|
|
56
|
+
expect(outside).not.toHaveBeenCalled()
|
|
57
|
+
// An empty filter lets Escape through, so the drawer still closes on it.
|
|
58
|
+
escape()
|
|
59
|
+
expect(outside).toHaveBeenCalledTimes(1)
|
|
60
|
+
document.removeEventListener('keydown', outside)
|
|
61
|
+
})
|
|
62
|
+
it('reads and reports display settings through the parent when it holds them', () => {
|
|
63
|
+
const onChange = vi.fn()
|
|
64
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
65
|
+
act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} pinned={[{ name: 'pods', kind: 'Pod', group: '' }]} displayState={{ showEmptyKinds: false, favoritesExpanded: false }} onDisplayStateChange={onChange} />))
|
|
66
|
+
const favorites = [...host.querySelectorAll('button')].find((b) => b.textContent?.toLowerCase().includes('favorites'))!
|
|
67
|
+
expect(favorites.getAttribute('aria-expanded')).toBe('false')
|
|
68
|
+
act(() => favorites.click())
|
|
69
|
+
expect(onChange).toHaveBeenCalledWith({ showEmptyKinds: false, favoritesExpanded: true })
|
|
70
|
+
})
|
|
@@ -79,6 +79,18 @@ export interface ResourcesSidebarProps {
|
|
|
79
79
|
/** Task destinations keyed by category name (e.g. "CloudNativePG"). A category
|
|
80
80
|
* with a workspace stays visible even when it has no resources. */
|
|
81
81
|
categoryWorkspaces?: Record<string, SidebarCategoryWorkspace>
|
|
82
|
+
/** Display settings held by the parent, for a sidebar that unmounts between
|
|
83
|
+
* uses (the phone drawer) and should not reset them each time it opens. */
|
|
84
|
+
displayState?: SidebarDisplayState
|
|
85
|
+
onDisplayStateChange?: (state: SidebarDisplayState) => void
|
|
86
|
+
/** Escape in a non-empty kind filter clears it without letting the key
|
|
87
|
+
* reach an enclosing dialog, which would close. */
|
|
88
|
+
containEscape?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SidebarDisplayState {
|
|
92
|
+
showEmptyKinds: boolean
|
|
93
|
+
favoritesExpanded: boolean
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
// Persisted across remounts so collapsed categories survive tab switches
|
|
@@ -250,6 +262,9 @@ export function ResourcesSidebar({
|
|
|
250
262
|
basePath,
|
|
251
263
|
onKindNavigated,
|
|
252
264
|
categoryWorkspaces,
|
|
265
|
+
displayState,
|
|
266
|
+
onDisplayStateChange,
|
|
267
|
+
containEscape,
|
|
253
268
|
}: ResourcesSidebarProps) {
|
|
254
269
|
// Wraps kind selection to also navigate when basePath/onNavigate are provided
|
|
255
270
|
const selectKind = (kind: SelectedKindInfo) => {
|
|
@@ -267,8 +282,13 @@ export function ResourcesSidebar({
|
|
|
267
282
|
() => persistedExpandedCategories ?? new Set(['Workloads', 'Networking', 'Configuration'])
|
|
268
283
|
)
|
|
269
284
|
useEffect(() => { persistedExpandedCategories = expandedCategories }, [expandedCategories])
|
|
270
|
-
const [
|
|
271
|
-
const
|
|
285
|
+
const [ownDisplayState, setOwnDisplayState] = useState<SidebarDisplayState>(() => ({ showEmptyKinds: false, favoritesExpanded: pinned.length > 0 }))
|
|
286
|
+
const { showEmptyKinds, favoritesExpanded } = displayState ?? ownDisplayState
|
|
287
|
+
const setDisplayState = (patch: Partial<SidebarDisplayState>) => {
|
|
288
|
+
const next = { showEmptyKinds, favoritesExpanded, ...patch }
|
|
289
|
+
if (onDisplayStateChange) onDisplayStateChange(next)
|
|
290
|
+
else setOwnDisplayState(next)
|
|
291
|
+
}
|
|
272
292
|
const favoritesDisclosure = useDisclosure(favoritesExpanded)
|
|
273
293
|
// Category sections are mapped inline, so they can't each call useDisclosure;
|
|
274
294
|
// one generated prefix plus the category name keeps aria-controls unique.
|
|
@@ -529,6 +549,7 @@ export function ResourcesSidebar({
|
|
|
529
549
|
|
|
530
550
|
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
531
551
|
if (e.key === 'Escape') {
|
|
552
|
+
if (containEscape && kindFilter) e.stopPropagation()
|
|
532
553
|
setKindFilter('')
|
|
533
554
|
setHighlightedIndex(-1)
|
|
534
555
|
;(e.target as HTMLInputElement).blur()
|
|
@@ -546,7 +567,7 @@ export function ResourcesSidebar({
|
|
|
546
567
|
setKindFilter('')
|
|
547
568
|
onKindNavigated?.()
|
|
548
569
|
}
|
|
549
|
-
}, [visibleResults.length, highlightedResult, selectKind, onKindNavigated]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
570
|
+
}, [visibleResults.length, highlightedResult, selectKind, onKindNavigated, containEscape, kindFilter]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
550
571
|
|
|
551
572
|
const isKindHighlighted = useCallback((name: string, group: string) => {
|
|
552
573
|
return highlightedKind?.name === name && highlightedKind?.group === group
|
|
@@ -603,7 +624,7 @@ export function ResourcesSidebar({
|
|
|
603
624
|
<div className="mb-2">
|
|
604
625
|
<button
|
|
605
626
|
{...favoritesDisclosure.buttonProps}
|
|
606
|
-
onClick={() =>
|
|
627
|
+
onClick={() => setDisplayState({ favoritesExpanded: !favoritesExpanded })}
|
|
607
628
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
|
|
608
629
|
>
|
|
609
630
|
<CollapseChevron open={favoritesExpanded} className="w-3 h-3" />
|
|
@@ -778,7 +799,7 @@ export function ResourcesSidebar({
|
|
|
778
799
|
{/* Toggle for showing/hiding empty kinds and groups */}
|
|
779
800
|
{hiddenKindsCount > 0 || hiddenGroupsCount > 0 || showEmptyKinds ? (
|
|
780
801
|
<button
|
|
781
|
-
onClick={() =>
|
|
802
|
+
onClick={() => setDisplayState({ showEmptyKinds: !showEmptyKinds })}
|
|
782
803
|
className="w-full flex items-center gap-2 px-3 py-2 mt-2 text-xs text-theme-text-tertiary hover:text-theme-text-secondary border-t border-theme-border"
|
|
783
804
|
>
|
|
784
805
|
{showEmptyKinds ? (
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { VirtuosoMockContext } from 'react-virtuoso'
|
|
5
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
6
|
+
import { ResourcesView } from './ResourcesView'
|
|
7
|
+
import { getCellFilterValue, serializeColumnFilters } from './resource-utils'
|
|
8
|
+
import { getResourceStatus } from '../shared/ResourceRendererDispatch'
|
|
9
|
+
import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
|
|
10
|
+
import type { APIResource } from '../../types'
|
|
11
|
+
import type { PrinterTable } from './printer-columns'
|
|
12
|
+
|
|
13
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
14
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
15
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
16
|
+
let root: ReturnType<typeof createRoot>
|
|
17
|
+
let host: HTMLDivElement
|
|
18
|
+
|
|
19
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear(); window.history.replaceState(null, '', '/') })
|
|
20
|
+
|
|
21
|
+
const pause = { type: 'Synced', status: 'False', reason: 'ReconcilePaused' }
|
|
22
|
+
const mr = { providerConfigRef: { name: 'default' } }
|
|
23
|
+
const xr = { resourceRefs: [] }
|
|
24
|
+
const claim = { resourceRef: { kind: 'Archive', name: 'xr' }, compositionRef: { name: 'archive' } }
|
|
25
|
+
|
|
26
|
+
it.each([
|
|
27
|
+
['managed', 'test.crossplane.io', mr, false],
|
|
28
|
+
['foreign-group managed', 'example.com', mr, false],
|
|
29
|
+
['composite default', 'example.com', xr, false],
|
|
30
|
+
['composite printer', 'example.com', xr, true],
|
|
31
|
+
['claim printer', 'example.com', claim, true],
|
|
32
|
+
['ref-less early composite printer', 'example.com', {}, true],
|
|
33
|
+
['ref-less early claim default', 'example.com', {}, false],
|
|
34
|
+
] as const)('keeps pause context and primary evidence on the real %s table path', async (_name, group, spec, printer) => {
|
|
35
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
36
|
+
const kind: APIResource = { group, version: 'v1', kind: 'Archive', name: 'archives', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
37
|
+
const resource = {
|
|
38
|
+
apiVersion: `${group}/v1`, kind: 'Archive',
|
|
39
|
+
metadata: { name: 'acknowledged', namespace: 'test', uid: 'fixture', annotations: { 'crossplane.io/paused': 'true' } },
|
|
40
|
+
spec, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] },
|
|
41
|
+
}
|
|
42
|
+
const printerTable: PrinterTable | undefined = printer ? {
|
|
43
|
+
kind: kind.name, group,
|
|
44
|
+
columns: [
|
|
45
|
+
{ name: 'Synced', jsonPath: ".status.conditions[?(@.type=='Synced')].status", type: 'string' },
|
|
46
|
+
{ name: 'Ready', jsonPath: ".status.conditions[?(@.type=='Ready')].status", type: 'string' },
|
|
47
|
+
],
|
|
48
|
+
cells: { fixture: ['False', 'False'] },
|
|
49
|
+
} : undefined
|
|
50
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
|
|
51
|
+
namespaces={['test']}
|
|
52
|
+
apiResources={[kind]}
|
|
53
|
+
resourceCounts={{ Archive: 1 }}
|
|
54
|
+
selectedKindQuery={{ isLoading: false, data: [resource], resourceName: kind.name, group }}
|
|
55
|
+
locationPathname="/resources/archives"
|
|
56
|
+
locationSearch={`?apiGroup=${group}`}
|
|
57
|
+
onNavigate={() => {}}
|
|
58
|
+
printerTable={printerTable}
|
|
59
|
+
/></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
60
|
+
const row = host.querySelector('tbody tr')!
|
|
61
|
+
expect(row.textContent).toContain('acknowledged')
|
|
62
|
+
expect(row.textContent?.match(/Paused/g)).toHaveLength(1)
|
|
63
|
+
if (printer) {
|
|
64
|
+
const printed = [...row.querySelectorAll('td')].filter(cell => cell.textContent?.trim() === 'False')
|
|
65
|
+
expect(printed).toHaveLength(2)
|
|
66
|
+
expect(printed[0].querySelector('.status-neutral')).not.toBeNull()
|
|
67
|
+
expect(printed[1].querySelector('.status-unhealthy')).not.toBeNull()
|
|
68
|
+
expect(row.textContent).not.toContain('Unavailable')
|
|
69
|
+
} else {
|
|
70
|
+
expect(row.textContent).toContain('Unavailable')
|
|
71
|
+
expect(row.textContent).not.toContain('Out of sync')
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
it('preserves curated package install failure ahead of a recorded pause in the table and filter', async () => {
|
|
77
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
78
|
+
const group = 'pkg.crossplane.io'
|
|
79
|
+
const kind: APIResource = { group, version: 'v1', kind: 'Provider', name: 'providers', namespaced: false, isCrd: true, verbs: ['get', 'list'] }
|
|
80
|
+
const resource = {
|
|
81
|
+
apiVersion: `${group}/v1`, kind: 'Provider',
|
|
82
|
+
metadata: { name: 'install-failure', uid: 'package-fixture', annotations: { 'crossplane.io/paused': 'true' } },
|
|
83
|
+
spec: { package: 'example.invalid/provider:v1' },
|
|
84
|
+
status: { conditions: [pause, { type: 'Installed', status: 'False', reason: 'UnpackFailed' }, { type: 'Healthy', status: 'True' }] },
|
|
85
|
+
}
|
|
86
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
|
|
87
|
+
namespaces={[]}
|
|
88
|
+
apiResources={[kind]}
|
|
89
|
+
resourceCounts={{ Provider: 1 }}
|
|
90
|
+
selectedKindQuery={{ isLoading: false, data: [resource], resourceName: kind.name, group }}
|
|
91
|
+
locationPathname="/resources/providers"
|
|
92
|
+
locationSearch={`?apiGroup=${group}`}
|
|
93
|
+
onNavigate={() => {}}
|
|
94
|
+
/></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
95
|
+
const row = host.querySelector('tbody tr')!
|
|
96
|
+
expect(row.textContent).toContain('UnpackFailed')
|
|
97
|
+
expect(row.querySelector('.status-unhealthy')).not.toBeNull()
|
|
98
|
+
expect(row.textContent).not.toContain('Readiness unknown')
|
|
99
|
+
expect(getCellFilterValue(resource, 'status', 'providers')).toBe('UnpackFailed')
|
|
100
|
+
expect(getCellFilterValue(resource, 'status', 'providers')).toBe(getResourceStatus('providers', resource)?.text)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
it.each([
|
|
105
|
+
['ref-less generic', 'example.com', 'Archive', 'archives', {}, 'Ready (reported)', 'Ready (reported)'],
|
|
106
|
+
['managed-name collision', 'acm.aws.upbound.io', 'Certificate', 'certificates', mr, 'Ready (reported)', 'Ready (reported)'],
|
|
107
|
+
['curated Knative', 'serving.knative.dev', 'Revision', 'revisions', {}, 'Ready', 'Scaled to Zero'],
|
|
108
|
+
] as const)('routes the actual %s status filter through the existing column policy', async (_name, group, kindName, plural, spec, filterText, cellText) => {
|
|
109
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
110
|
+
const kind: APIResource = { group, version: 'v1', kind: kindName, name: plural, namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
111
|
+
const resource = {
|
|
112
|
+
apiVersion: `${group}/v1`, kind: kindName,
|
|
113
|
+
metadata: { name: 'kept', namespace: 'test', uid: 'kept-fixture', annotations: { 'crossplane.io/paused': 'true' } },
|
|
114
|
+
spec, status: { conditions: [pause, { type: 'Ready', status: 'True' }, { type: 'Active', status: 'False', reason: 'NoTraffic' }] },
|
|
115
|
+
}
|
|
116
|
+
const excluded = { ...resource, metadata: { ...resource.metadata, name: 'excluded', uid: 'excluded-fixture' }, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] } }
|
|
117
|
+
const search = `?${new URLSearchParams({ apiGroup: group, filters: serializeColumnFilters({ status: [filterText] }) })}`
|
|
118
|
+
window.history.replaceState(null, '', `/resources/${plural}${search}`)
|
|
119
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
|
|
120
|
+
namespaces={['test']}
|
|
121
|
+
apiResources={[kind]}
|
|
122
|
+
resourceCounts={{ [kindName]: 2 }}
|
|
123
|
+
selectedKindQuery={{ isLoading: false, data: [resource, excluded], resourceName: plural, group }}
|
|
124
|
+
locationPathname={`/resources/${plural}`}
|
|
125
|
+
locationSearch={search}
|
|
126
|
+
onNavigate={() => {}}
|
|
127
|
+
/></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
128
|
+
const rows = host.querySelectorAll('tbody tr')
|
|
129
|
+
expect(rows).toHaveLength(1)
|
|
130
|
+
expect(rows[0].textContent).toContain('kept')
|
|
131
|
+
expect(rows[0].textContent).toContain(cellText)
|
|
132
|
+
expect(rows[0].textContent).not.toContain('excluded')
|
|
133
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { VirtuosoMockContext } from 'react-virtuoso'
|
|
5
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
6
|
+
import { ResourcesView } from './ResourcesView'
|
|
7
|
+
import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
|
|
8
|
+
import type { APIResource } from '../../types'
|
|
9
|
+
|
|
10
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
11
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
12
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
13
|
+
let root: ReturnType<typeof createRoot>
|
|
14
|
+
let host: HTMLDivElement
|
|
15
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
|
|
16
|
+
|
|
17
|
+
it('shows failed rollout and suspension together in the actual Canary table', async () => {
|
|
18
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
19
|
+
const kind: APIResource = { group: 'flagger.app', version: 'v1beta1', kind: 'Canary', name: 'canaries', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
|
|
20
|
+
const data = { apiVersion: 'flagger.app/v1beta1', kind: 'Canary', metadata: { name: 'storefront', namespace: 'test' }, spec: { suspend: true, targetRef: { kind: 'Deployment', name: 'storefront' }, analysis: { iterations: 2 } }, status: { phase: 'Failed' } }
|
|
21
|
+
await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView namespaces={['test']} apiResources={[kind]} resourceCounts={{ Canary: 1 }} selectedKindQuery={{ isLoading: false, data: [data], resourceName: kind.name, group: kind.group }} locationPathname="/resources/canaries" locationSearch="?apiGroup=flagger.app" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
|
|
22
|
+
const table = host.querySelector('table')!
|
|
23
|
+
expect(table).not.toBeNull()
|
|
24
|
+
expect(table.textContent).toContain('Rollout State')
|
|
25
|
+
expect(table.textContent).toContain('Failed')
|
|
26
|
+
expect(table.querySelector('.status-unhealthy')).not.toBeNull()
|
|
27
|
+
expect(table.textContent).toContain('Blue/green')
|
|
28
|
+
expect(table.textContent).toContain('Not weighted')
|
|
29
|
+
expect(table.textContent).toContain('Suspend Requested')
|
|
30
|
+
expect(table.textContent).toContain('Yes')
|
|
31
|
+
})
|