@skyhook-io/k8s-ui 1.18.1 → 1.18.2
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/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/issues/IssuesView.tsx +2 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -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 +53 -1
- 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
package/README.md
CHANGED
|
@@ -83,3 +83,11 @@ tooltip. `certaintyGlyph` and `certaintyValueLabel` expose the same vocabulary.
|
|
|
83
83
|
`ActionConfirmDialog` adds context, reviewed writes and optional typed acknowledgement to the simpler `ConfirmDialog`. Hosts own loading, permission checks, stale-fact validation and result classification. Its `result` binds the state (`error`, `unknown` or `partial`), message and optional heading; unknown and partial results lock another submit. Retain that result outside the dialog across dismissal and remount, and clear it only after explicitly reviewing current state. The component does not infer outcomes from HTTP status. Remount it when changing the reviewed target or action, so acknowledgement and disclosure state start fresh. The default error heading makes no claim about whether a write occurred; use a stronger result title only when the producer proves it.
|
|
84
84
|
|
|
85
85
|
Existing CNPG primitive exports remain available and delegate to these shared implementations.
|
|
86
|
+
|
|
87
|
+
`PrinterColumnDef.jsonPath` optionally carries source provenance from newer Radar servers. The UI does not evaluate it: values still come from the server's table converter. Without it, printer-cell tones stay generic. Crossplane pause qualification only recognizes exact Ready/Synced condition paths and corroborated annotation/condition evidence; same-named unrelated columns retain their normal tones. `ConditionTone` also supports opt-in `neutral`; `defaultConditionTone` retains its original return vocabulary. `ComposedRefStatus.pauseState` optionally supplies recorded Crossplane pause context for a composed-resource row.
|
|
88
|
+
|
|
89
|
+
`ForceDeleteConfirmDialog.resourceData` optionally supplies the current object for Crossplane pause context. Paused MR/XR/Claim shapes holding a known Crossplane finalizer receive a cleanup warning; the prop does not change confirmation or force-delete behavior. YAML operation context uses the existing baseline and predicted manifests; removing a pause annotation requests resumption rather than proving it.
|
|
90
|
+
|
|
91
|
+
`YamlReview.mode` distinguishes Apply, strict Create and Update (default: `apply`). Hosts using strict creation should pass `create` so an unpreviewable new document does not imply unknown live pause state. Crossplane operation context reports what the server preview actually keeps or clears; unsupported dry-run documents retain conditional uncertainty rather than treating submitted YAML as a live baseline.
|
|
92
|
+
|
|
93
|
+
For a single editor target, `YamlReview.resourceData` may retain its raw condition evidence when the review YAML intentionally omits status. `EditableYamlView` binds that snapshot to its preview. It is not used to classify unrelated documents in a batch. Generic Apply with neither a known Crossplane shape nor raw acknowledged pause does not infer a resource’s controller from the annotation alone.
|
package/package.json
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { ReadyBar } from './ReadyBar'
|
|
47
47
|
import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
|
|
48
48
|
import { AppIdentityTooltip, EnvHint, RolloutSummaryTooltip } from './AppTooltips'
|
|
49
|
+
import { FilterRailPanel } from '../shared/FilterRailPanel'
|
|
49
50
|
|
|
50
51
|
function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps: AppRow[]; workloadClass: AppWorkloadClass; ready: number; desired: number }) {
|
|
51
52
|
if (workloadClass !== 'job') {
|
|
@@ -337,6 +338,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
337
338
|
// showSystem lives in the Filters rail, so Clear resets it too (and its
|
|
338
339
|
// non-default ON state counts as an active filter that surfaces the button).
|
|
339
340
|
const anyFilterActive = filters.isActive
|
|
341
|
+
const activeFilterCount = fHealth.size + fClass.size + fType.size + fEnv.size + fSource.size + (systemCount > 0 && showSystem ? 1 : 0)
|
|
340
342
|
const clearAllFilters = () => {
|
|
341
343
|
filters.clearAll()
|
|
342
344
|
}
|
|
@@ -355,7 +357,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
355
357
|
actions={
|
|
356
358
|
<>
|
|
357
359
|
{headerActions}
|
|
358
|
-
<SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} loading={initialLoading} />
|
|
360
|
+
<SummaryTile label={total === 1 ? 'application' : 'applications'} phoneLabel={total === 1 ? 'app' : 'apps'} value={total} loading={initialLoading} />
|
|
359
361
|
{healthTile('unhealthy', 'error')}
|
|
360
362
|
{healthTile('degraded', 'warning')}
|
|
361
363
|
{healthTile('healthy', 'success')}
|
|
@@ -381,13 +383,12 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
381
383
|
Arbitrary 900px breakpoint (not `sm`) so the collapse is independent of
|
|
382
384
|
the consuming app's Tailwind breakpoint config (Hub uses defaults). */}
|
|
383
385
|
<aside className="flex w-52 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-[899px]:max-h-72 max-[899px]:w-full max-[899px]:border-b max-[899px]:border-r-0">
|
|
384
|
-
<
|
|
385
|
-
|
|
386
|
-
{anyFilterActive && (
|
|
386
|
+
<FilterRailPanel
|
|
387
|
+
activeCount={activeFilterCount}
|
|
388
|
+
clear={anyFilterActive && (
|
|
387
389
|
<button type="button" onClick={clearAllFilters} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">Clear</button>
|
|
388
390
|
)}
|
|
389
|
-
|
|
390
|
-
<div className="flex-1 overflow-y-auto">
|
|
391
|
+
>
|
|
391
392
|
{initialLoading ? (
|
|
392
393
|
<BoardRailSkeleton
|
|
393
394
|
sections={[
|
|
@@ -414,7 +415,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
414
415
|
)}
|
|
415
416
|
</>
|
|
416
417
|
)}
|
|
417
|
-
</
|
|
418
|
+
</FilterRailPanel>
|
|
418
419
|
</aside>
|
|
419
420
|
|
|
420
421
|
{/* Content: toolbar (search + sort) over the scrollable table. */}
|
|
@@ -12,6 +12,7 @@ import { ClusterName } from '../ui/ClusterName'
|
|
|
12
12
|
import { Input } from '../ui/Input'
|
|
13
13
|
import { MiddleEllipsis } from '../ui/MiddleEllipsis'
|
|
14
14
|
import { StatusDot, type StatusTone } from '../ui/status-tone'
|
|
15
|
+
import { useKeepInViewport } from '../../hooks/useKeepInViewport'
|
|
15
16
|
|
|
16
17
|
export interface ClusterSwitcherItem {
|
|
17
18
|
id: string
|
|
@@ -107,6 +108,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
|
|
|
107
108
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
108
109
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
109
110
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
111
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
112
|
+
useKeepInViewport(menuRef, isOpen)
|
|
110
113
|
|
|
111
114
|
useImperativeHandle(ref, () => ({
|
|
112
115
|
open: () => {
|
|
@@ -284,6 +287,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
|
|
|
284
287
|
|
|
285
288
|
{isOpen && (
|
|
286
289
|
<div
|
|
290
|
+
ref={menuRef}
|
|
287
291
|
className={`absolute top-full ${positionClass} mt-1 z-50 min-w-[280px] max-w-[420px] bg-theme-surface border border-theme-border-light rounded-lg shadow-xl overflow-hidden`}
|
|
288
292
|
>
|
|
289
293
|
{searchable && (
|
|
@@ -5,6 +5,7 @@ import { YamlDiffEditor } from '../ui/YamlEditor'
|
|
|
5
5
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
6
6
|
import { Tooltip } from '../ui/Tooltip'
|
|
7
7
|
import { pluralToKind } from '../../utils/navigation'
|
|
8
|
+
import { usePhoneLayout } from '../../hooks/usePhoneLayout'
|
|
8
9
|
import { toComparableYaml } from './normalize'
|
|
9
10
|
import { SIDE_TONES, type CompareSide } from './types'
|
|
10
11
|
|
|
@@ -120,7 +121,10 @@ export function ResourceCompareView({
|
|
|
120
121
|
bleed = false,
|
|
121
122
|
}: ResourceCompareViewProps) {
|
|
122
123
|
const [specOnly, setSpecOnly] = useState(false)
|
|
123
|
-
const [
|
|
124
|
+
const [unifiedPicked, setUnified] = useState(false)
|
|
125
|
+
// Two side-by-side panes don't fit a phone, so phones always get one column.
|
|
126
|
+
const isPhone = usePhoneLayout()
|
|
127
|
+
const unified = unifiedPicked || isPhone
|
|
124
128
|
const [hideUnchanged, setHideUnchanged] = useState(true)
|
|
125
129
|
const [rawMetadata, setRawMetadata] = useState(false)
|
|
126
130
|
|
|
@@ -180,7 +184,7 @@ export function ResourceCompareView({
|
|
|
180
184
|
<ToggleButton active={rawMetadata} onClick={() => setRawMetadata(v => !v)} icon={<Sparkles className="w-3.5 h-3.5" />} label="Raw metadata" tooltip="Keep server-assigned noise (uid, resourceVersion, managedFields, last-applied)" />
|
|
181
185
|
<ToggleButton active={specOnly} onClick={() => setSpecOnly(v => !v)} icon={<FileCode2 className="w-3.5 h-3.5" />} label="Spec only" tooltip="Drop status fields from both sides" />
|
|
182
186
|
<ToggleButton active={hideUnchanged} onClick={() => setHideUnchanged(v => !v)} icon={<FileText className="w-3.5 h-3.5" />} label="Diff only" tooltip="Collapse unchanged regions" />
|
|
183
|
-
<ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />
|
|
187
|
+
{!isPhone && <ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />}
|
|
184
188
|
</div>
|
|
185
189
|
|
|
186
190
|
<div className="w-px h-5 bg-theme-border shrink-0" aria-hidden />
|
|
@@ -41,6 +41,7 @@ import { toggleSet } from './GitOpsGraphFilterRail'
|
|
|
41
41
|
import { useFilterState, defineFilterSchema } from '../../filter-state'
|
|
42
42
|
import { parseContextName } from '../../utils/context-name'
|
|
43
43
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format'
|
|
44
|
+
import { FilterRailPanel } from '../shared/FilterRailPanel'
|
|
44
45
|
|
|
45
46
|
// =============================================================================
|
|
46
47
|
// GitOpsTableView — the canonical GitOps fleet list (Argo + Flux).
|
|
@@ -937,13 +938,17 @@ function GitOpsFilterSidebar({
|
|
|
937
938
|
side === 'right' ? 'border-l max-[899px]:border-l-0' : 'border-r max-[899px]:border-r-0'
|
|
938
939
|
}`}
|
|
939
940
|
>
|
|
940
|
-
<
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
941
|
+
<FilterRailPanel
|
|
942
|
+
activeCount={
|
|
943
|
+
syncFilters.size + healthFilters.size + automationFilters.size + kindFilters.size +
|
|
944
|
+
projectFilters.size + namespaceFilters.size + (lifecycleFilter === 'all' ? 0 : 1)
|
|
945
|
+
}
|
|
946
|
+
clear={
|
|
947
|
+
<button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
|
|
948
|
+
Clear
|
|
949
|
+
</button>
|
|
950
|
+
}
|
|
951
|
+
>
|
|
947
952
|
{loading ? (
|
|
948
953
|
<BoardRailSkeleton
|
|
949
954
|
sections={[
|
|
@@ -1062,7 +1067,7 @@ function GitOpsFilterSidebar({
|
|
|
1062
1067
|
</GitOpsFilterSection>
|
|
1063
1068
|
</>
|
|
1064
1069
|
)}
|
|
1065
|
-
</
|
|
1070
|
+
</FilterRailPanel>
|
|
1066
1071
|
</aside>
|
|
1067
1072
|
)
|
|
1068
1073
|
}
|
|
@@ -88,8 +88,8 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
|
|
|
88
88
|
tone="healthy"
|
|
89
89
|
variant="card"
|
|
90
90
|
icon={CircleCheck}
|
|
91
|
-
headline="
|
|
92
|
-
body="
|
|
91
|
+
headline="No issues detected"
|
|
92
|
+
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
93
|
/>
|
|
94
94
|
) : (
|
|
95
95
|
<EmptyState headline="No issue data yet" body="Connect a cluster to populate the issue queue." action={emptyAction} />
|
|
@@ -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
|
}
|
|
@@ -140,6 +140,7 @@ export function categoryLabel(category: string): string {
|
|
|
140
140
|
// Reasons filed under a category whose label names a different operation:
|
|
141
141
|
// CNPG archiving and an unanswered scheduled run sit under backup_failed.
|
|
142
142
|
const REASON_TITLE: Record<string, string> = {
|
|
143
|
+
FlaggerCanaryFailed: "Canary rollout failed",
|
|
143
144
|
InvalidSchedule: 'Scheduling error reported',
|
|
144
145
|
UnparseableSchedule: 'Scheduling error reported',
|
|
145
146
|
UnParseableCronJobSchedule: 'Scheduling error reported',
|
|
@@ -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
|
+
})
|