@skyhook-io/k8s-ui 1.8.10 → 1.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsView.tsx +62 -10
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +2 -3
- package/src/components/checks/severity.ts +14 -10
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +3 -3
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +51 -38
- package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
- package/src/components/issues/ResourceIssuesSection.tsx +28 -146
- package/src/components/issues/index.ts +3 -0
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +102 -1
- package/src/components/issues/severity.ts +2 -0
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
- package/src/components/resources/ResourcesSidebar.tsx +46 -46
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +233 -51
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +4 -0
- package/src/components/ui/severity-tone.ts +11 -4
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +1 -1
- package/src/types/core.ts +57 -4
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
|
@@ -4,6 +4,7 @@ import { renderToString } from 'react-dom/server'
|
|
|
4
4
|
import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
|
|
5
5
|
import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
|
|
6
6
|
import { IssueRow } from './IssuesView'
|
|
7
|
+
import { issueTiming } from './issue-timing'
|
|
7
8
|
|
|
8
9
|
const base: Issue = {
|
|
9
10
|
id: 'id-0',
|
|
@@ -78,7 +79,7 @@ describe('category/group label fallbacks', () => {
|
|
|
78
79
|
})
|
|
79
80
|
|
|
80
81
|
describe('IssueRow', () => {
|
|
81
|
-
it('keeps relative age visible and adds
|
|
82
|
+
it('keeps relative age visible and adds deployment timing as a secondary tag', () => {
|
|
82
83
|
vi.useFakeTimers()
|
|
83
84
|
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
84
85
|
|
|
@@ -96,6 +97,25 @@ describe('IssueRow', () => {
|
|
|
96
97
|
expect(html).toContain('since deploy')
|
|
97
98
|
})
|
|
98
99
|
|
|
100
|
+
it('promotes after-healthy timing in the collapsed row', () => {
|
|
101
|
+
vi.useFakeTimers()
|
|
102
|
+
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
103
|
+
|
|
104
|
+
const html = renderToString(createElement(IssueRow, {
|
|
105
|
+
issue: mk({
|
|
106
|
+
id: 'regression',
|
|
107
|
+
first_seen: '2026-06-30T10:00:00Z',
|
|
108
|
+
issue_timing: 'started_after_resource_was_healthy',
|
|
109
|
+
issue_timing_basis: 'condition',
|
|
110
|
+
}),
|
|
111
|
+
open: false,
|
|
112
|
+
onToggle: () => undefined,
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
expect(html).toContain('2h')
|
|
116
|
+
expect(html).toContain('after healthy')
|
|
117
|
+
})
|
|
118
|
+
|
|
99
119
|
it('uses the category-group chip class in the queue row', () => {
|
|
100
120
|
const html = renderToString(createElement(IssueRow, {
|
|
101
121
|
issue: mk({ category_group: 'control_plane' }),
|
|
@@ -108,6 +128,87 @@ describe('IssueRow', () => {
|
|
|
108
128
|
})
|
|
109
129
|
})
|
|
110
130
|
|
|
131
|
+
describe('issueTiming', () => {
|
|
132
|
+
it('keeps deployment wording for deployment-like creation failures', () => {
|
|
133
|
+
expect(issueTiming(mk({
|
|
134
|
+
kind: 'Deployment',
|
|
135
|
+
group: 'apps',
|
|
136
|
+
issue_timing: 'started_at_resource_creation',
|
|
137
|
+
issue_timing_basis: 'condition',
|
|
138
|
+
}))).toMatchObject({
|
|
139
|
+
kind: 'creation',
|
|
140
|
+
chip: 'since deploy',
|
|
141
|
+
meta: 'present since deployment or first reconciliation',
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(issueTiming(mk({
|
|
145
|
+
kind: 'Rollout',
|
|
146
|
+
group: 'argoproj.io',
|
|
147
|
+
issue_timing: 'started_at_resource_creation',
|
|
148
|
+
issue_timing_basis: 'condition',
|
|
149
|
+
}))).toMatchObject({
|
|
150
|
+
kind: 'creation',
|
|
151
|
+
chip: 'since deploy',
|
|
152
|
+
meta: 'present since deployment or first reconciliation',
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('uses deployment wording for pod issues only when workload timing evidence is present', () => {
|
|
157
|
+
expect(issueTiming(mk({
|
|
158
|
+
kind: 'Pod',
|
|
159
|
+
issue_timing: 'started_at_resource_creation',
|
|
160
|
+
issue_timing_basis: 'pod_creation',
|
|
161
|
+
}))).toMatchObject({
|
|
162
|
+
kind: 'creation',
|
|
163
|
+
chip: 'since deploy',
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
expect(issueTiming(mk({
|
|
167
|
+
kind: 'Pod',
|
|
168
|
+
issue_timing: 'started_at_resource_creation',
|
|
169
|
+
issue_timing_basis: 'phase',
|
|
170
|
+
}))).toMatchObject({
|
|
171
|
+
kind: 'creation',
|
|
172
|
+
chip: 'since creation',
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('uses resource creation wording for non-deployment creation failures', () => {
|
|
177
|
+
expect(issueTiming(mk({
|
|
178
|
+
kind: 'PersistentVolumeClaim',
|
|
179
|
+
issue_timing: 'started_at_resource_creation',
|
|
180
|
+
issue_timing_basis: 'phase',
|
|
181
|
+
}))).toMatchObject({
|
|
182
|
+
kind: 'creation',
|
|
183
|
+
chip: 'since creation',
|
|
184
|
+
meta: 'present since creation or first reconciliation',
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('describes after-healthy timing without implying root cause or safety', () => {
|
|
189
|
+
vi.useFakeTimers()
|
|
190
|
+
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
191
|
+
|
|
192
|
+
const display = issueTiming(mk({
|
|
193
|
+
first_seen: '2026-06-30T10:00:00Z',
|
|
194
|
+
issue_timing: 'started_after_resource_was_healthy',
|
|
195
|
+
issue_timing_basis: 'condition',
|
|
196
|
+
}))
|
|
197
|
+
|
|
198
|
+
expect(display).toMatchObject({
|
|
199
|
+
kind: 'regression',
|
|
200
|
+
chip: 'after healthy',
|
|
201
|
+
meta: 'started 2h ago after being healthy',
|
|
202
|
+
tooltip: 'Previously healthy before this failing signal.',
|
|
203
|
+
})
|
|
204
|
+
expect(`${display?.chip} ${display?.meta} ${display?.tooltip}`).not.toMatch(/baseline|safe|ignore/i)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('returns null when there is no confident timing signal', () => {
|
|
208
|
+
expect(issueTiming(mk({ issue_timing: undefined, issue_timing_basis: undefined }))).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
111
212
|
describe('subjectRef / memberRef', () => {
|
|
112
213
|
it('subjectRef defaults empty group/namespace and threads cluster_id', () => {
|
|
113
214
|
const issue = mk({ cluster_id: 'cl_1', kind: 'Deployment', name: 'web' }) // no group/namespace
|
|
@@ -2,6 +2,7 @@ import type { IssueSeverity } from './types';
|
|
|
2
2
|
import { BADGE_SEVERITY_COLORS as sev } from '../ui/Badge';
|
|
3
3
|
import { NEUTRAL_CHIP_CLASS } from '../ui/CardSection';
|
|
4
4
|
import {
|
|
5
|
+
TONE_FILL_CLASS,
|
|
5
6
|
TONE_HEADER_BAND_CLASS,
|
|
6
7
|
TONE_RAIL_CLASS,
|
|
7
8
|
TONE_SOLID_CLASS,
|
|
@@ -36,6 +37,7 @@ export const ISSUE_SEVERITY_BADGE_CLASS: Record<IssueSeverity, string> = {
|
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
export const ISSUE_SEVERITY_TEXT_CLASS = byTone(TONE_TEXT_CLASS);
|
|
40
|
+
export const ISSUE_SEVERITY_FILL_CLASS = byTone(TONE_FILL_CLASS);
|
|
39
41
|
export const ISSUE_SEVERITY_RAIL_CLASS = byTone(TONE_RAIL_CLASS);
|
|
40
42
|
export const ISSUE_SEVERITY_SOLID_CLASS = byTone(TONE_SOLID_CLASS);
|
|
41
43
|
export const ISSUE_SEVERITY_HEADER_BAND_CLASS = byTone(TONE_HEADER_BAND_CLASS);
|
|
@@ -5,6 +5,7 @@ import type { LogEntry, LogLevel } from './useLogBuffer'
|
|
|
5
5
|
import { useLogSearch } from './useLogSearch'
|
|
6
6
|
import { StructuredLogLine } from './StructuredLogLine'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { Input } from '../ui/Input'
|
|
8
9
|
import {
|
|
9
10
|
formatLogTimestamp,
|
|
10
11
|
highlightSearchMatches,
|
|
@@ -37,6 +38,7 @@ interface LogCoreProps {
|
|
|
37
38
|
toolbarExtra?: ToolbarExtraRenderer
|
|
38
39
|
showPodName?: boolean
|
|
39
40
|
emptyMessage?: string
|
|
41
|
+
emptyCommand?: string | null
|
|
40
42
|
errorMessage?: string | null
|
|
41
43
|
/**
|
|
42
44
|
* Hard override for the viewer palette. When set, the viewer stays pinned to
|
|
@@ -88,6 +90,8 @@ const STRUCTURED_MODE_DESCRIPTIONS: Record<StructuredMode, string> = {
|
|
|
88
90
|
raw: 'Original log line, unparsed',
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
const EMPTY_STATE_CLASS = 'flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center'
|
|
94
|
+
|
|
91
95
|
const TIMESTAMP_FORMAT_SHORT_LABELS: Record<TimestampFormat, string> = {
|
|
92
96
|
'time-local': 'Local time',
|
|
93
97
|
'time-utc': 'UTC time',
|
|
@@ -124,6 +128,7 @@ export function LogCore({
|
|
|
124
128
|
toolbarExtra,
|
|
125
129
|
showPodName = false,
|
|
126
130
|
emptyMessage = 'No logs available',
|
|
131
|
+
emptyCommand,
|
|
127
132
|
errorMessage,
|
|
128
133
|
forceDark,
|
|
129
134
|
}: LogCoreProps) {
|
|
@@ -694,8 +699,7 @@ export function LogCore({
|
|
|
694
699
|
{search.isOpen && (
|
|
695
700
|
<div className={`flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
|
|
696
701
|
<Search className={`w-4 h-4 ${palette.textSecondary} shrink-0`} />
|
|
697
|
-
<
|
|
698
|
-
type="text"
|
|
702
|
+
<Input
|
|
699
703
|
value={search.query}
|
|
700
704
|
onChange={(e) => search.setQuery(e.target.value)}
|
|
701
705
|
onKeyDown={(e) => {
|
|
@@ -800,14 +804,20 @@ export function LogCore({
|
|
|
800
804
|
</div>
|
|
801
805
|
</div>
|
|
802
806
|
) : errorMessage ? (
|
|
803
|
-
<div className={
|
|
807
|
+
<div className={`${EMPTY_STATE_CLASS} ${palette.textError}`}>
|
|
804
808
|
<Terminal className="w-8 h-8" />
|
|
805
809
|
<span>{errorMessage}</span>
|
|
806
810
|
</div>
|
|
807
811
|
) : groupedEntries.length === 0 ? (
|
|
808
|
-
<div className={
|
|
812
|
+
<div className={`${EMPTY_STATE_CLASS} ${palette.textTertiary}`}>
|
|
809
813
|
<Terminal className="w-8 h-8" />
|
|
810
814
|
<span>{emptyMessage}</span>
|
|
815
|
+
{emptyCommand && (
|
|
816
|
+
<button type="button" onClick={() => navigator.clipboard.writeText(emptyCommand).catch(() => {})} className={`mt-2 inline-flex max-w-[80%] items-center gap-2 rounded border px-3 py-2 font-mono text-xs ${palette.border} ${palette.toolbarBg}`} title="Copy recovery command">
|
|
817
|
+
<code className="truncate">{emptyCommand}</code>
|
|
818
|
+
<Copy className="h-3.5 w-3.5 shrink-0" />
|
|
819
|
+
</button>
|
|
820
|
+
)}
|
|
811
821
|
</div>
|
|
812
822
|
) : (
|
|
813
823
|
<div className="flex-1 relative">
|
|
@@ -27,6 +27,9 @@ export interface WorkloadLogsFetchParams {
|
|
|
27
27
|
export interface WorkloadLogsResult {
|
|
28
28
|
pods: WorkloadPodInfo[]
|
|
29
29
|
logs: WorkloadRawLog[]
|
|
30
|
+
emptyReason?: string
|
|
31
|
+
emptyMessage?: string
|
|
32
|
+
command?: string
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export interface WorkloadLogsViewerProps {
|
|
@@ -61,6 +64,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
61
64
|
const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
|
|
62
65
|
const [isLoading, setIsLoading] = useState(false)
|
|
63
66
|
const [fetchError, setFetchError] = useState<string | null>(null)
|
|
67
|
+
const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
|
|
68
|
+
const [emptyCommand, setEmptyCommand] = useState<string | null>(null)
|
|
64
69
|
const [showPodFilter, setShowPodFilter] = useState(false)
|
|
65
70
|
const [logRange, setLogRange] = useState('100')
|
|
66
71
|
const { showError, showSuccess } = useToast()
|
|
@@ -95,9 +100,10 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
95
100
|
setFetchError(null)
|
|
96
101
|
try {
|
|
97
102
|
const result = await fetchAll({ container: selectedContainer || undefined, tailLines, sinceSeconds })
|
|
98
|
-
// Older backends marshal empty results as null rather than [].
|
|
99
103
|
const resultPods = result.pods ?? []
|
|
100
104
|
const resultLogs = result.logs ?? []
|
|
105
|
+
setEmptyMessage(result.emptyMessage || null)
|
|
106
|
+
setEmptyCommand(result.command || null)
|
|
101
107
|
|
|
102
108
|
podColorIndexRef.current = new Map(resultPods.map((pod, i) => [pod.name, i]))
|
|
103
109
|
setPods(resultPods)
|
|
@@ -157,6 +163,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
157
163
|
const nextPods = data.pods as WorkloadPodInfo[]
|
|
158
164
|
podColorIndexRef.current = new Map(nextPods.map((pod, i) => [pod.name, i]))
|
|
159
165
|
setPods(nextPods)
|
|
166
|
+
setEmptyMessage(data.emptyMessage || null)
|
|
167
|
+
setEmptyCommand(data.command || null)
|
|
160
168
|
setSelectedPods(prev => (
|
|
161
169
|
prev.size === 0 ? new Set(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
|
|
162
170
|
))
|
|
@@ -180,8 +188,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
180
188
|
const existing = new Set(prev.map(p => p.name))
|
|
181
189
|
const toAdd = newPods.filter(p => !existing.has(p.name))
|
|
182
190
|
if (toAdd.length === 0) return prev
|
|
183
|
-
|
|
191
|
+
const next = [...prev, ...toAdd]
|
|
192
|
+
podColorIndexRef.current = new Map(next.map((pod, i) => [pod.name, i]))
|
|
193
|
+
return next
|
|
184
194
|
})
|
|
195
|
+
setEmptyMessage(null)
|
|
196
|
+
setEmptyCommand(null)
|
|
185
197
|
setSelectedPods(prev => {
|
|
186
198
|
const next = new Set(prev)
|
|
187
199
|
newPods.forEach(p => next.add(p.name))
|
|
@@ -197,6 +209,10 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
197
209
|
// while new pod logs start flowing in
|
|
198
210
|
}
|
|
199
211
|
},
|
|
212
|
+
onEnd: (data: any) => {
|
|
213
|
+
if (data?.emptyMessage) setEmptyMessage(data.emptyMessage)
|
|
214
|
+
if (data?.command) setEmptyCommand(data.command)
|
|
215
|
+
},
|
|
200
216
|
},
|
|
201
217
|
'Workload log stream connection failed',
|
|
202
218
|
)
|
|
@@ -299,8 +315,13 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
299
315
|
</div>
|
|
300
316
|
{pods.map(pod => {
|
|
301
317
|
const dotBg = palette.podColors[(podColorIndex.get(pod.name) ?? 0) % palette.podColors.length].bg
|
|
318
|
+
const primaryLabel = pod.stepName || pod.name
|
|
319
|
+
const secondaryLabel = pod.stepName ? pod.name : ''
|
|
320
|
+
const stateLabel = pod.stepPhase || pod.phase || (pod.ready ? 'Ready' : 'Not Ready')
|
|
302
321
|
let readyColor: string
|
|
303
|
-
if (
|
|
322
|
+
if (stateLabel === 'Failed' || stateLabel === 'Error') {
|
|
323
|
+
readyColor = isDark ? 'text-red-400' : 'text-red-700'
|
|
324
|
+
} else if (pod.ready || stateLabel === 'Succeeded') {
|
|
304
325
|
readyColor = isDark ? 'text-emerald-400' : 'text-emerald-700'
|
|
305
326
|
} else {
|
|
306
327
|
readyColor = isDark ? 'text-amber-400' : 'text-amber-700'
|
|
@@ -314,9 +335,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
314
335
|
className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
|
|
315
336
|
/>
|
|
316
337
|
<span className={`w-2 h-2 rounded-full ${dotBg}`} />
|
|
317
|
-
<span className=
|
|
338
|
+
<span className="min-w-0 flex-1">
|
|
339
|
+
<span className={`block truncate text-xs ${palette.textPrimary}`}>{primaryLabel}</span>
|
|
340
|
+
{secondaryLabel && <span className={`block truncate text-[10px] ${palette.textTertiary}`}>{secondaryLabel}</span>}
|
|
341
|
+
</span>
|
|
318
342
|
<span className={`text-xs ${readyColor}`}>
|
|
319
|
-
{
|
|
343
|
+
{stateLabel}
|
|
320
344
|
</span>
|
|
321
345
|
</label>
|
|
322
346
|
)
|
|
@@ -360,7 +384,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
360
384
|
onClear={clear}
|
|
361
385
|
toolbarExtra={renderToolbarExtra}
|
|
362
386
|
showPodName
|
|
363
|
-
emptyMessage={pods.length === 0 ? 'No pods found' : 'No logs available'}
|
|
387
|
+
emptyMessage={emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
|
|
388
|
+
emptyCommand={emptyCommand}
|
|
364
389
|
errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
|
|
365
390
|
forceDark={forceDark}
|
|
366
391
|
/>
|
|
@@ -10,6 +10,8 @@ export interface LogStreamHandlers {
|
|
|
10
10
|
onPodAdded?: (data: unknown) => void
|
|
11
11
|
/** Called when pods are terminated during streaming (workload logs only) */
|
|
12
12
|
onPodRemoved?: (data: unknown) => void
|
|
13
|
+
/** Called when the server ends the stream cleanly */
|
|
14
|
+
onEnd?: (data: unknown) => void
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -90,11 +92,16 @@ export function useLogStream() {
|
|
|
90
92
|
}
|
|
91
93
|
})
|
|
92
94
|
|
|
93
|
-
es.addEventListener('end', () => {
|
|
95
|
+
es.addEventListener('end', (event) => {
|
|
94
96
|
if (!isCurrent()) return
|
|
95
97
|
endedRef.current = true
|
|
96
98
|
setIsStreaming(false)
|
|
97
99
|
setConnecting(false)
|
|
100
|
+
if (handlers.onEnd) {
|
|
101
|
+
try { handlers.onEnd(JSON.parse((event as MessageEvent).data)) } catch (e) {
|
|
102
|
+
console.error('Failed to parse end event:', e)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
98
105
|
})
|
|
99
106
|
|
|
100
107
|
es.addEventListener('error', (event) => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { ChevronDown, Globe, Search, AlertTriangle
|
|
3
|
+
import { ChevronDown, Globe, Search, AlertTriangle } from 'lucide-react'
|
|
4
4
|
import { Tooltip } from '../ui/Tooltip'
|
|
5
|
+
import { MultiSelectPicker } from '../ui/MultiSelectPicker'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Backend-reported namespace scope. Mirrors Radar's `/cluster/namespace-scope`
|
|
@@ -192,18 +193,6 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
192
193
|
applySelection(new Set())
|
|
193
194
|
}
|
|
194
195
|
|
|
195
|
-
const selectAllVisible = () => {
|
|
196
|
-
const next = new Set(draft)
|
|
197
|
-
for (const ns of filteredItems) next.add(ns)
|
|
198
|
-
setDraft(next)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const clearVisible = () => {
|
|
202
|
-
const next = new Set(draft)
|
|
203
|
-
for (const ns of filteredItems) next.delete(ns)
|
|
204
|
-
setDraft(next)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
196
|
const activeCount = scopeActives.length
|
|
208
197
|
const triggerLabel =
|
|
209
198
|
activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
|
|
@@ -226,11 +215,6 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
226
215
|
? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
|
|
227
216
|
: `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
|
|
228
217
|
|
|
229
|
-
// Counts used to label the bulk-action buttons; computed against the visible
|
|
230
|
-
// (filtered) set so the labels match what the action will affect.
|
|
231
|
-
const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
|
|
232
|
-
const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
|
|
233
|
-
|
|
234
218
|
return (
|
|
235
219
|
<>
|
|
236
220
|
<Tooltip
|
|
@@ -271,100 +255,95 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
271
255
|
style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
|
|
272
256
|
className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
|
|
273
257
|
>
|
|
274
|
-
{items.length > 6 && (
|
|
275
|
-
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
|
|
276
|
-
<Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
277
|
-
<input
|
|
278
|
-
autoFocus
|
|
279
|
-
value={search}
|
|
280
|
-
onChange={e => setSearch(e.target.value)}
|
|
281
|
-
placeholder="Filter namespaces"
|
|
282
|
-
className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
|
|
283
|
-
/>
|
|
284
|
-
</div>
|
|
285
|
-
)}
|
|
286
|
-
|
|
287
258
|
{scope.cacheScoped ? (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
309
|
-
>
|
|
310
|
-
{allVisibleSelected
|
|
311
|
-
? `Clear ${filteredItems.length} visible`
|
|
312
|
-
: search.trim()
|
|
313
|
-
? `Select ${filteredItems.length} visible`
|
|
314
|
-
: 'Select all'}
|
|
315
|
-
</button>
|
|
316
|
-
</div>
|
|
317
|
-
)}
|
|
259
|
+
<>
|
|
260
|
+
{items.length > 6 && (
|
|
261
|
+
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
|
|
262
|
+
<Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
263
|
+
<input
|
|
264
|
+
autoFocus
|
|
265
|
+
value={search}
|
|
266
|
+
onChange={e => setSearch(e.target.value)}
|
|
267
|
+
placeholder="Filter namespaces"
|
|
268
|
+
className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
<div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
|
|
274
|
+
Radar is watching one namespace to stay fast on large clusters.
|
|
275
|
+
{scope.namespaceRescope
|
|
276
|
+
? ' Pick another to re-point it — takes a moment and closes open terminals.'
|
|
277
|
+
: ' This instance is locked to its startup namespace.'}
|
|
278
|
+
</div>
|
|
318
279
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
280
|
+
<ul className="max-h-80 overflow-y-auto py-1">
|
|
281
|
+
{filteredItems.length === 0 && (
|
|
282
|
+
<li className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
283
|
+
{search ? 'No matches.' : 'No namespaces available.'}
|
|
284
|
+
</li>
|
|
285
|
+
)}
|
|
325
286
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
287
|
+
{filteredItems.map(ns => {
|
|
288
|
+
const isChecked = draft.has(ns)
|
|
289
|
+
const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
|
|
290
|
+
return (
|
|
291
|
+
<li key={ns}>
|
|
292
|
+
<label className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer">
|
|
293
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
294
|
+
<input
|
|
295
|
+
type="radio"
|
|
296
|
+
name="namespace-cache-scope"
|
|
297
|
+
checked={isChecked}
|
|
298
|
+
onChange={() => toggle(ns)}
|
|
299
|
+
className="shrink-0 accent-current"
|
|
300
|
+
/>
|
|
301
|
+
<span className="truncate">{ns}</span>
|
|
302
|
+
{isContextDefault && (
|
|
303
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
304
|
+
kubeconfig
|
|
305
|
+
</span>
|
|
306
|
+
)}
|
|
346
307
|
</span>
|
|
347
|
-
|
|
348
|
-
</
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
})}
|
|
353
|
-
</ul>
|
|
308
|
+
</label>
|
|
309
|
+
</li>
|
|
310
|
+
)
|
|
311
|
+
})}
|
|
312
|
+
</ul>
|
|
354
313
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
314
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
315
|
+
<span>{draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace'}</span>
|
|
316
|
+
<button
|
|
317
|
+
onClick={closeAndApply}
|
|
318
|
+
className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
|
|
319
|
+
>
|
|
320
|
+
Done
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
</>
|
|
324
|
+
) : (
|
|
325
|
+
<MultiSelectPicker
|
|
326
|
+
items={items}
|
|
327
|
+
selected={draft}
|
|
328
|
+
onSelectionChange={setDraft}
|
|
329
|
+
onClearAll={clearAll}
|
|
330
|
+
onDone={closeAndApply}
|
|
331
|
+
search={search}
|
|
332
|
+
onSearchChange={setSearch}
|
|
333
|
+
searchPlaceholder="Filter namespaces"
|
|
334
|
+
summaryEmptyLabel="All namespaces"
|
|
335
|
+
noItemsLabel="No namespaces available."
|
|
336
|
+
clearAllDisabled={!canClearAll || activeCount === 0}
|
|
337
|
+
clearAllAriaLabel="Clear namespace selection"
|
|
338
|
+
renderItemMeta={ns =>
|
|
339
|
+
ns === scope.kubeconfigNamespace && ns !== '' ? (
|
|
340
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
341
|
+
kubeconfig
|
|
342
|
+
</span>
|
|
343
|
+
) : null
|
|
344
|
+
}
|
|
345
|
+
/>
|
|
346
|
+
)}
|
|
368
347
|
|
|
369
348
|
{!scope.authoritative && (
|
|
370
349
|
<div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
|
|
@@ -13,6 +13,16 @@ const sqlInstance: APIResource = {
|
|
|
13
13
|
verbs: ['list'],
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const horizontalPodAutoscaler: APIResource = {
|
|
17
|
+
group: 'autoscaling',
|
|
18
|
+
version: 'v2',
|
|
19
|
+
kind: 'HorizontalPodAutoscaler',
|
|
20
|
+
name: 'horizontalpodautoscalers',
|
|
21
|
+
namespaced: true,
|
|
22
|
+
isCrd: false,
|
|
23
|
+
verbs: ['list'],
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
describe('ResourcesSidebar CRD group labels', () => {
|
|
17
27
|
it('keeps raw API groups available for filtering and hover recovery', () => {
|
|
18
28
|
expect(resourceMatchesSidebarFilter(sqlInstance, 'cnrm')).toBe(true)
|
|
@@ -34,3 +44,64 @@ describe('ResourcesSidebar CRD group labels', () => {
|
|
|
34
44
|
expect(html).toContain('title="API group: sql.cnrm.cloud.google.com"')
|
|
35
45
|
})
|
|
36
46
|
})
|
|
47
|
+
|
|
48
|
+
describe('ResourcesSidebar count visibility', () => {
|
|
49
|
+
it('keeps resources visible when their count is unknown', () => {
|
|
50
|
+
const html = renderToString(
|
|
51
|
+
<ResourcesSidebar
|
|
52
|
+
selectedKind={null}
|
|
53
|
+
onSelectedKindChange={() => {}}
|
|
54
|
+
apiResources={[horizontalPodAutoscaler]}
|
|
55
|
+
resourceCounts={{}}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(html).toContain('HorizontalPodAutoscaler')
|
|
60
|
+
expect(html).toContain('–')
|
|
61
|
+
expect(html).toContain('aria-label="Count unavailable. Open to view resources."')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('hides confirmed-empty resources by default', () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
<ResourcesSidebar
|
|
67
|
+
selectedKind={null}
|
|
68
|
+
onSelectedKindChange={() => {}}
|
|
69
|
+
apiResources={[horizontalPodAutoscaler]}
|
|
70
|
+
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(html).not.toContain('HorizontalPodAutoscaler')
|
|
75
|
+
expect(html).toContain('Show')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps the selected confirmed-empty resource visible', () => {
|
|
79
|
+
const html = renderToString(
|
|
80
|
+
<ResourcesSidebar
|
|
81
|
+
selectedKind={{ name: 'horizontalpodautoscalers', kind: 'HorizontalPodAutoscaler', group: 'autoscaling' }}
|
|
82
|
+
onSelectedKindChange={() => {}}
|
|
83
|
+
apiResources={[horizontalPodAutoscaler]}
|
|
84
|
+
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(html).toContain('HorizontalPodAutoscaler')
|
|
89
|
+
expect(html).toContain('>0</')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('keeps pinned confirmed-empty resources visible in Favorites', () => {
|
|
93
|
+
const html = renderToString(
|
|
94
|
+
<ResourcesSidebar
|
|
95
|
+
selectedKind={null}
|
|
96
|
+
onSelectedKindChange={() => {}}
|
|
97
|
+
apiResources={[horizontalPodAutoscaler]}
|
|
98
|
+
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
99
|
+
pinned={[{ name: 'horizontalpodautoscalers', kind: 'HorizontalPodAutoscaler', group: 'autoscaling' }]}
|
|
100
|
+
isPinned={(name, group) => name === 'horizontalpodautoscalers' && group === 'autoscaling'}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
expect(html).toContain('Favorites')
|
|
105
|
+
expect(html).toContain('HorizontalPodAutoscaler')
|
|
106
|
+
})
|
|
107
|
+
})
|