@skyhook-io/k8s-ui 1.18.0 → 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 +21 -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/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- 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/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- 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.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- 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/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- 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-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -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 +21 -8
- 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.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- 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/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- 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 +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- 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/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -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 +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { argoApplicationSetConditionsToGitOpsStatus, argoStatusToGitOpsStatus, fluxConditionsToGitOpsStatus, type FluxCondition, type GitOpsStatus } from '../../types/gitops'
|
|
2
2
|
import type { GitOpsResourceTree } from '../../types/gitops-tree'
|
|
3
3
|
import { formatCompactAge } from '../../utils/format'
|
|
4
|
+
import { apiVersionToGroup } from '../../utils/navigation'
|
|
4
5
|
|
|
5
6
|
// =============================================================================
|
|
6
7
|
// Shared helpers used by both per-cluster Radar's GitOps detail view (OSS
|
|
@@ -92,12 +93,31 @@ export function describeGitOpsTerminating(summary?: {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
const FLUX_KIND_GROUPS: Readonly<Record<string, string | undefined>> = {
|
|
97
|
+
gitrepositories: 'source.toolkit.fluxcd.io',
|
|
98
|
+
ocirepositories: 'source.toolkit.fluxcd.io',
|
|
99
|
+
helmrepositories: 'source.toolkit.fluxcd.io',
|
|
100
|
+
helmcharts: 'source.toolkit.fluxcd.io',
|
|
101
|
+
buckets: 'source.toolkit.fluxcd.io',
|
|
102
|
+
kustomizations: 'kustomize.toolkit.fluxcd.io',
|
|
103
|
+
helmreleases: 'helm.toolkit.fluxcd.io',
|
|
104
|
+
alerts: 'notification.toolkit.fluxcd.io',
|
|
105
|
+
}
|
|
106
|
+
const ARGO_CD_KINDS = new Set(['applications', 'applicationsets', 'appprojects'])
|
|
107
|
+
|
|
108
|
+
|
|
95
109
|
// getGitOpsResourceStatus dispatches sync/health extraction by kind. Argo
|
|
96
110
|
// Applications read from .status (rich operationState + sync state); Argo
|
|
97
111
|
// ApplicationSets read their own generator conditions; Flux kinds read
|
|
98
112
|
// .status.conditions + .spec.suspend. Returns null if the resource has
|
|
99
113
|
// no recognizable status (e.g. just-created, status not yet populated).
|
|
100
114
|
export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStatus | null {
|
|
115
|
+
// Existing callers also pass status-only objects with a controller-specific
|
|
116
|
+
// kind. A loaded API identity, when present, must agree with that kind.
|
|
117
|
+
const group = resource?.apiVersion ? apiVersionToGroup(resource.apiVersion)
|
|
118
|
+
: ARGO_CD_KINDS.has(kind) ? 'argoproj.io' : FLUX_KIND_GROUPS[kind]
|
|
119
|
+
const tool = getGitOpsTool(kind, group)
|
|
120
|
+
if (!tool) return null
|
|
101
121
|
if (kind === 'applications') {
|
|
102
122
|
return argoStatusToGitOpsStatus(resource?.status ?? {})
|
|
103
123
|
}
|
|
@@ -110,6 +130,7 @@ export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStat
|
|
|
110
130
|
(resource?.status?.conditions ?? []) as FluxCondition[]
|
|
111
131
|
)
|
|
112
132
|
}
|
|
133
|
+
if (tool === 'argo') return null
|
|
113
134
|
const conditions = (resource?.status?.conditions ?? []) as FluxCondition[]
|
|
114
135
|
return fluxConditionsToGitOpsStatus(conditions, resource?.spec?.suspend === true, {
|
|
115
136
|
generation: resource?.metadata?.generation,
|
|
@@ -117,12 +138,22 @@ export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStat
|
|
|
117
138
|
})
|
|
118
139
|
}
|
|
119
140
|
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
141
|
+
|
|
142
|
+
// getGitOpsTool decides the controller family from the API group, the same
|
|
143
|
+
// rule the backend applies (pkg/gitops ToolFor). The kind alone is not
|
|
144
|
+
// identity: SIG Apps (app.k8s.io) defines an Application too, and argoproj.io
|
|
145
|
+
// is shared with Argo Rollouts and Workflows. null means the object is not a
|
|
146
|
+
// supported GitOps resource and has no GitOps view or operations.
|
|
147
|
+
export function getGitOpsTool(kind: string, group: string | undefined): 'argo' | 'flux' | null {
|
|
148
|
+
if (group === 'argoproj.io') return ARGO_CD_KINDS.has(kind) ? 'argo' : null
|
|
149
|
+
if (group && FLUX_KIND_GROUPS[kind] === group) return 'flux'
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// gitOpsDetailGroup is the API group a detail page acts on: the fetched
|
|
154
|
+
// object's own apiVersion once it has loaded, the route's group before that.
|
|
155
|
+
export function gitOpsDetailGroup(routeGroup: string, resource: { apiVersion?: string } | null | undefined): string {
|
|
156
|
+
return resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : routeGroup
|
|
126
157
|
}
|
|
127
158
|
|
|
128
159
|
// isDestinationRef reports whether a resource named on a remote GitOps
|
|
@@ -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} />
|
|
@@ -288,13 +288,16 @@ export function IssueRow({
|
|
|
288
288
|
onToggle();
|
|
289
289
|
}
|
|
290
290
|
}}
|
|
291
|
-
className={`group @container/issue flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
291
|
+
className={`group @container/issue flex cursor-pointer items-center gap-3 phone:flex-wrap phone:gap-y-2 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
292
292
|
>
|
|
293
293
|
<SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
|
|
294
294
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
295
|
+
{/* Phones: the title takes its own line with the badge and reason
|
|
296
|
+
under it, and the severity, actions and chevron get a last line
|
|
297
|
+
(the right cluster below wraps under the text). */}
|
|
298
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1 phone:basis-[calc(100%-30px)]">
|
|
299
|
+
<div className="flex min-w-0 items-baseline gap-2 phone:flex-wrap phone:gap-y-1">
|
|
300
|
+
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary phone:basis-full">{issueTitle(issue)}</span>
|
|
298
301
|
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
299
302
|
{renderBadges?.(slotCtx)}
|
|
300
303
|
{/* The detector reason rides the title row while COLLAPSED so the
|
|
@@ -351,15 +354,17 @@ export function IssueRow({
|
|
|
351
354
|
{/* Narrow container: the meta chips drop to their own line here rather
|
|
352
355
|
than stealing width from the title on the row above. Hidden once the
|
|
353
356
|
row is wide enough to hold them inline in the right cluster. */}
|
|
354
|
-
{metaChips('flex @2xl/issue:hidden')}
|
|
357
|
+
{metaChips('flex @2xl/issue:hidden phone:hidden')}
|
|
355
358
|
</div>
|
|
356
359
|
|
|
357
360
|
{/* Right cluster — the always-visible affordances (per-row action +
|
|
358
361
|
chevron) plus, on a wide container, the inline meta chips. */}
|
|
359
|
-
<div className="flex shrink-0 items-center gap-3">
|
|
360
|
-
{metaChips('hidden @2xl/issue:flex')}
|
|
361
|
-
|
|
362
|
-
|
|
362
|
+
<div className="flex shrink-0 items-center gap-3 phone:basis-full phone:pl-[30px]">
|
|
363
|
+
{metaChips('hidden @2xl/issue:flex phone:flex')}
|
|
364
|
+
<div className="contents phone:ml-auto phone:flex phone:items-center phone:gap-3">
|
|
365
|
+
{renderActions?.(slotCtx)}
|
|
366
|
+
<CollapseChevron open={open} className="h-4 w-4" />
|
|
367
|
+
</div>
|
|
363
368
|
</div>
|
|
364
369
|
</div>
|
|
365
370
|
|
|
@@ -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" />
|
|
@@ -60,16 +60,16 @@ afterEach(async () => {
|
|
|
60
60
|
vi.unstubAllGlobals()
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false, containers = ['app']) {
|
|
63
|
+
async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false, containers = ['app'], podName = 'web-0') {
|
|
64
64
|
await act(async () =>
|
|
65
65
|
root.render(
|
|
66
66
|
<ToastProvider>
|
|
67
67
|
<LogsViewer
|
|
68
68
|
namespace="default"
|
|
69
|
-
podName=
|
|
69
|
+
podName={podName}
|
|
70
70
|
containers={containers}
|
|
71
71
|
fetchLogs={fetchLogs}
|
|
72
|
-
createStream={() => new EventSource(
|
|
72
|
+
createStream={() => new EventSource(`/pods/default/${podName}/logs/stream`)}
|
|
73
73
|
autoStream={autoStream}
|
|
74
74
|
/>
|
|
75
75
|
</ToastProvider>,
|
|
@@ -234,62 +234,6 @@ it('with auto-stream on, a time range picked after the stream ended loads it ins
|
|
|
234
234
|
expect(fetchLogs).toHaveBeenLastCalledWith(expect.objectContaining({ previous: true, sinceSeconds: expect.any(Number) }))
|
|
235
235
|
})
|
|
236
236
|
|
|
237
|
-
it('with auto-stream on, a range picked while the stream opens reopens it with that range', async () => {
|
|
238
|
-
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
239
|
-
await renderViewer(fetchLogs, true)
|
|
240
|
-
const opening = FakeEventSource.last
|
|
241
|
-
|
|
242
|
-
const range = element.querySelector<HTMLSelectElement>('select')
|
|
243
|
-
if (!range) throw new Error('range select not rendered')
|
|
244
|
-
await act(async () => {
|
|
245
|
-
range.value = range.options[range.options.length - 1].value
|
|
246
|
-
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
expect(opening?.closed).toBe(true)
|
|
250
|
-
expect(FakeEventSource.last).not.toBe(opening)
|
|
251
|
-
expect(fetchLogs).not.toHaveBeenCalled()
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
it('with auto-stream on, after the stream ended and was started again, a range picked while it opens reopens it', async () => {
|
|
255
|
-
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
256
|
-
await renderViewer(fetchLogs, true)
|
|
257
|
-
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
258
|
-
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
259
|
-
const range = element.querySelector<HTMLSelectElement>('select')
|
|
260
|
-
if (!range) throw new Error('range select not rendered')
|
|
261
|
-
const pick = (index: number) => act(async () => {
|
|
262
|
-
range.value = range.options[index].value
|
|
263
|
-
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
264
|
-
})
|
|
265
|
-
await pick(range.options.length - 1)
|
|
266
|
-
await act(async () => button('Stream').click())
|
|
267
|
-
const opening = FakeEventSource.last
|
|
268
|
-
|
|
269
|
-
await pick(range.options.length - 2)
|
|
270
|
-
|
|
271
|
-
expect(opening?.closed).toBe(true)
|
|
272
|
-
expect(FakeEventSource.last).not.toBe(opening)
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
it('a range picked while a manually started stream opens does not cancel it', async () => {
|
|
276
|
-
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
277
|
-
await renderViewer(fetchLogs, true)
|
|
278
|
-
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
279
|
-
await act(async () => button('Stop').click())
|
|
280
|
-
await act(async () => button('Stream').click())
|
|
281
|
-
const opening = FakeEventSource.last
|
|
282
|
-
const range = element.querySelector<HTMLSelectElement>('select')
|
|
283
|
-
if (!range) throw new Error('range select not rendered')
|
|
284
|
-
|
|
285
|
-
await act(async () => {
|
|
286
|
-
range.value = range.options[range.options.length - 1].value
|
|
287
|
-
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
expect(opening?.closed).toBe(false)
|
|
291
|
-
})
|
|
292
|
-
|
|
293
237
|
it('with auto-stream on, the open stream is not replaced by a snapshot', async () => {
|
|
294
238
|
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
295
239
|
await renderViewer(fetchLogs, true)
|
|
@@ -367,3 +311,104 @@ it('still shows a Radar failure as an error', async () => {
|
|
|
367
311
|
await renderViewer(async () => { throw new ApiError(failure, { error: failure }) })
|
|
368
312
|
expect(messageClass(failure)).toMatch(/text-red/)
|
|
369
313
|
})
|
|
314
|
+
|
|
315
|
+
function rangeSelect(): HTMLSelectElement {
|
|
316
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
317
|
+
if (!range) throw new Error('range select not rendered')
|
|
318
|
+
return range
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const nextFrame = () => act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve(null))) })
|
|
322
|
+
|
|
323
|
+
it('opening another pod with the same container name streams it, even after the first pod\'s stream ended', async () => {
|
|
324
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
325
|
+
await renderViewer(fetchLogs, true, ['app'], 'web-0')
|
|
326
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
327
|
+
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
328
|
+
const ended = FakeEventSource.last
|
|
329
|
+
|
|
330
|
+
await renderViewer(fetchLogs, true, ['app'], 'web-1')
|
|
331
|
+
|
|
332
|
+
expect(FakeEventSource.last).not.toBe(ended)
|
|
333
|
+
expect(FakeEventSource.last?.url).toBe('/pods/default/web-1/logs/stream')
|
|
334
|
+
expect(fetchLogs).not.toHaveBeenCalled()
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('a manual Stop on one pod does not stop the next pod from streaming', async () => {
|
|
338
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
339
|
+
await renderViewer(fetchLogs, true, ['app'], 'web-0')
|
|
340
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
341
|
+
await act(async () => button('Stop').click())
|
|
342
|
+
|
|
343
|
+
await renderViewer(fetchLogs, true, ['app'], 'web-1')
|
|
344
|
+
|
|
345
|
+
expect(FakeEventSource.last?.url).toBe('/pods/default/web-1/logs/stream')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('Previous and the range are locked while an auto-stream opens', async () => {
|
|
349
|
+
await renderViewer(async () => ({ app: '' }), true)
|
|
350
|
+
expect(previousCheckbox().disabled).toBe(true)
|
|
351
|
+
expect(rangeSelect().disabled).toBe(true)
|
|
352
|
+
|
|
353
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
354
|
+
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
355
|
+
expect(previousCheckbox().disabled).toBe(false)
|
|
356
|
+
expect(rangeSelect().disabled).toBe(false)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('Previous and the range are locked while a manually started stream opens', async () => {
|
|
360
|
+
await renderViewer(async () => ({ app: '' }))
|
|
361
|
+
expect(previousCheckbox().disabled).toBe(false)
|
|
362
|
+
|
|
363
|
+
await act(async () => button('Stream').click())
|
|
364
|
+
|
|
365
|
+
expect(previousCheckbox().disabled).toBe(true)
|
|
366
|
+
expect(rangeSelect().disabled).toBe(true)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('a stream that is still opening can be stopped, which unlocks Previous', async () => {
|
|
370
|
+
await renderViewer(async () => ({ app: '' }))
|
|
371
|
+
await act(async () => button('Stream').click())
|
|
372
|
+
const opening = FakeEventSource.last
|
|
373
|
+
|
|
374
|
+
await act(async () => button('Stop').click())
|
|
375
|
+
|
|
376
|
+
expect(opening?.closed).toBe(true)
|
|
377
|
+
expect(previousCheckbox().disabled).toBe(false)
|
|
378
|
+
expect(button('Stream')).toBeTruthy()
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('a stream failure after lines arrived is shown above them', async () => {
|
|
382
|
+
await renderViewer(async () => ({ app: '' }))
|
|
383
|
+
await act(async () => button('Stream').click())
|
|
384
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
385
|
+
await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-14T10:00:00Z', content: 'live line', container: 'app' }))
|
|
386
|
+
await nextFrame()
|
|
387
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: 'Read error: unexpected EOF' }))
|
|
388
|
+
|
|
389
|
+
expect(element.querySelector('[role="alert"]')?.textContent).toBe('Read error: unexpected EOF · Previously loaded logs remain below.')
|
|
390
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
const NOT_STARTED = 'This container has not started yet, so it has no logs. Kubernetes reports ContainerCreating.'
|
|
394
|
+
|
|
395
|
+
it('shows a container that has not started in the empty-pane style', async () => {
|
|
396
|
+
await renderViewer(async () => { throw new ApiError(NOT_STARTED, { error: NOT_STARTED, error_code: 'container_not_started' }) })
|
|
397
|
+
expect(messageClass(NOT_STARTED)).not.toMatch(/text-red/)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('shows what the live stream is waiting for, and drops it once lines arrive', async () => {
|
|
401
|
+
const waiting = `${NOT_STARTED} Logs will show here once it starts.`
|
|
402
|
+
await renderViewer(async () => ({ app: '' }), true)
|
|
403
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
404
|
+
await act(async () => FakeEventSource.last?.emit('notice', { notice: waiting, notice_code: 'container_not_started' }))
|
|
405
|
+
expect(messageClass(waiting)).not.toMatch(/text-red/)
|
|
406
|
+
expect(button('Stop')).toBeTruthy()
|
|
407
|
+
|
|
408
|
+
await act(async () => FakeEventSource.last?.emit('notice', { notice: '' }))
|
|
409
|
+
await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-14T10:00:00Z', content: 'started', container: 'app' }))
|
|
410
|
+
await nextFrame()
|
|
411
|
+
|
|
412
|
+
expect(element.textContent).not.toContain(waiting)
|
|
413
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
414
|
+
})
|
|
@@ -43,7 +43,7 @@ export interface LogsViewerProps {
|
|
|
43
43
|
// Kubernetes' answers that there are no logs to show, such as "no earlier run".
|
|
44
44
|
// They describe the pod, not a failure in Radar, so they are shown like an
|
|
45
45
|
// empty pane rather than as an error.
|
|
46
|
-
const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable'])
|
|
46
|
+
const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable', 'container_not_started'])
|
|
47
47
|
|
|
48
48
|
function isClusterAnswerCode(code: unknown): boolean {
|
|
49
49
|
return typeof code === 'string' && CLUSTER_ANSWER_CODES.has(code)
|
|
@@ -56,7 +56,14 @@ function clusterAnswerOf(err: unknown): string | null {
|
|
|
56
56
|
return isClusterAnswerCode(data.error_code) ? err.message : null
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
// Keyed by pod: everything the viewer remembers (the container, Previous, the
|
|
60
|
+
// auto-start latch, a manual Stop) belongs to one pod, and a host that swaps
|
|
61
|
+
// the pod without remounting would carry it over to the next one.
|
|
62
|
+
export function LogsViewer(props: LogsViewerProps) {
|
|
63
|
+
return <PodLogsViewer key={`${props.namespace}/${props.podName}`} {...props} />
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function PodLogsViewer({
|
|
60
67
|
namespace: _namespace,
|
|
61
68
|
podName,
|
|
62
69
|
containers,
|
|
@@ -74,6 +81,8 @@ export function LogsViewer({
|
|
|
74
81
|
const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
|
|
75
82
|
const [logRange, setLogRange] = useState('500')
|
|
76
83
|
const [showPrevious, setShowPrevious] = useState(false)
|
|
84
|
+
// What an open stream says it is waiting for, e.g. a container to start.
|
|
85
|
+
const [streamNotice, setStreamNotice] = useState<string | null>(null)
|
|
77
86
|
const { showError, showSuccess } = useToast()
|
|
78
87
|
|
|
79
88
|
const { tailLines, sinceSeconds } = parseLogRange(logRange)
|
|
@@ -109,6 +118,7 @@ export function LogsViewer({
|
|
|
109
118
|
setIsLoading(true)
|
|
110
119
|
setFetchError(null)
|
|
111
120
|
setEmptyMessage(null)
|
|
121
|
+
setStreamNotice(null)
|
|
112
122
|
try {
|
|
113
123
|
const data = await fetchLogs({ container: selectedContainer, tailLines, sinceSeconds, previous: showPrevious })
|
|
114
124
|
if (seq !== loadSeqRef.current) return
|
|
@@ -171,6 +181,7 @@ export function LogsViewer({
|
|
|
171
181
|
setIsLoading(false)
|
|
172
182
|
setFetchError(null)
|
|
173
183
|
setEmptyMessage(null)
|
|
184
|
+
setStreamNotice(null)
|
|
174
185
|
// The stream replays the last N lines (TailLines + Follow); clear first so
|
|
175
186
|
// they don't duplicate lines already in the buffer (the snapshot on the
|
|
176
187
|
// manual path, or an earlier stream on restart).
|
|
@@ -184,6 +195,10 @@ export function LogsViewer({
|
|
|
184
195
|
content: data.content || '',
|
|
185
196
|
container: data.container || selectedContainer,
|
|
186
197
|
}),
|
|
198
|
+
onNotice: (data: unknown) => {
|
|
199
|
+
const notice = typeof data === 'object' && data !== null && 'notice' in data && typeof data.notice === 'string' ? data.notice : ''
|
|
200
|
+
setStreamNotice(notice || null)
|
|
201
|
+
},
|
|
187
202
|
},
|
|
188
203
|
'Log stream connection failed',
|
|
189
204
|
)
|
|
@@ -191,6 +206,7 @@ export function LogsViewer({
|
|
|
191
206
|
|
|
192
207
|
const handleStopStreaming = useCallback(() => {
|
|
193
208
|
userStoppedRef.current = true
|
|
209
|
+
setStreamNotice(null)
|
|
194
210
|
stopStreaming()
|
|
195
211
|
}, [stopStreaming])
|
|
196
212
|
|
|
@@ -220,24 +236,28 @@ export function LogsViewer({
|
|
|
220
236
|
}
|
|
221
237
|
}, [podName, selectedContainer, overrideDownload, showError, showSuccess])
|
|
222
238
|
|
|
239
|
+
// Previous and the range describe what a snapshot loads, so they stay
|
|
240
|
+
// locked from the moment a stream starts opening, not only once it is open.
|
|
241
|
+
const streamBusy = isStreaming || connecting
|
|
242
|
+
|
|
223
243
|
const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
|
|
224
244
|
<>
|
|
225
245
|
<ContainerSelect containers={containers} value={selectedContainer} onChange={setSelectedContainer} isDark={isDark} />
|
|
226
246
|
|
|
227
|
-
<Tooltip content={
|
|
228
|
-
<label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${
|
|
247
|
+
<Tooltip content={streamBusy ? 'Stop streaming to view the previous instance' : "Show logs from the pod's previous instance (if it was restarted). Useful for troubleshooting crashed containers."} position="bottom">
|
|
248
|
+
<label className={`flex items-center gap-1.5 text-xs ${palette.textSecondary} ${streamBusy ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
|
229
249
|
<input
|
|
230
250
|
type="checkbox"
|
|
231
251
|
checked={showPrevious}
|
|
232
252
|
onChange={(e) => setShowPrevious(e.target.checked)}
|
|
233
|
-
disabled={
|
|
253
|
+
disabled={streamBusy}
|
|
234
254
|
className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
|
|
235
255
|
/>
|
|
236
256
|
<span className={`border-b border-dotted ${isDark ? 'border-slate-500' : 'border-slate-400'}`}>Previous</span>
|
|
237
257
|
</label>
|
|
238
258
|
</Tooltip>
|
|
239
259
|
|
|
240
|
-
<LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={
|
|
260
|
+
<LogRangeSelect value={logRange} onChange={setLogRange} isDark={isDark} disabled={streamBusy} />
|
|
241
261
|
</>
|
|
242
262
|
)
|
|
243
263
|
|
|
@@ -260,13 +280,13 @@ export function LogsViewer({
|
|
|
260
280
|
getRetainedEntries={getRetainedEntries}
|
|
261
281
|
isLoading={isLoading || isConnecting}
|
|
262
282
|
errorMessage={entries.length === 0 ? fetchError || (!emptyMessage && !streamAnswer ? streamError : null) : null}
|
|
263
|
-
isStreaming={
|
|
283
|
+
isStreaming={streamBusy}
|
|
264
284
|
onStartStream={createStream ? handleStartStreaming : undefined}
|
|
265
285
|
onStopStream={handleStopStreaming}
|
|
266
286
|
onRefresh={loadLogs}
|
|
267
287
|
onDownload={downloadLogs}
|
|
268
288
|
onClear={clear}
|
|
269
|
-
emptyMessage={emptyMessage ?? streamAnswer ?? undefined}
|
|
289
|
+
emptyMessage={emptyMessage ?? streamAnswer ?? streamNotice ?? undefined}
|
|
270
290
|
toolbarExtra={renderToolbarExtra}
|
|
271
291
|
forceDark={forceDark}
|
|
272
292
|
defaultDark={defaultDark}
|
|
@@ -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)}
|