@skyhook-io/k8s-ui 1.7.12 → 1.7.14
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 +1 -1
- package/src/components/applications/AppChips.tsx +109 -0
- package/src/components/applications/AppTooltips.tsx +199 -0
- package/src/components/applications/ApplicationDetail.tsx +671 -0
- package/src/components/applications/ApplicationsList.tsx +569 -0
- package/src/components/applications/ReadyBar.tsx +22 -0
- package/src/components/applications/index.ts +8 -0
- package/src/components/audit/AuditFindingsTable.tsx +3 -25
- package/src/components/audit/index.ts +1 -1
- package/src/components/checks/ChecksView.tsx +269 -142
- package/src/components/checks/index.ts +14 -1
- package/src/components/issues/IssuesView.tsx +56 -17
- package/src/components/issues/index.ts +2 -2
- package/src/components/issues/issues.test.ts +7 -0
- package/src/components/issues/types.ts +15 -5
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/shared/ResourceActionsBar.tsx +6 -6
- package/src/components/timeline/TimelineList.tsx +3 -32
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
- package/src/components/topology/K8sResourceNode.tsx +26 -5
- package/src/components/topology/TopologyGraph.tsx +102 -3
- package/src/components/topology/layout.ts +36 -11
- package/src/components/ui/CenteredEmpty.tsx +27 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -2
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/drawer-components.tsx +8 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +170 -36
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +185 -0
- package/src/utils/topology-neighborhood.ts +262 -0
- package/src/utils/workload-colors.ts +36 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
|
2
2
|
import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
|
|
3
3
|
import { ClusterName, EmptyState } from '../ui';
|
|
4
4
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
@@ -90,25 +90,48 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
issue
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
onResourceClick,
|
|
100
|
-
}: {
|
|
93
|
+
export interface IssueRowSlotContext {
|
|
94
|
+
issue: Issue;
|
|
95
|
+
open: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface IssueRowProps {
|
|
101
99
|
issue: Issue;
|
|
102
100
|
clusterLabel?: (issue: Issue) => string | undefined;
|
|
103
101
|
open: boolean;
|
|
104
102
|
onToggle: () => void;
|
|
105
103
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
106
104
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
107
|
-
|
|
105
|
+
as?: 'li' | 'div';
|
|
106
|
+
className?: string;
|
|
107
|
+
dimmed?: boolean;
|
|
108
|
+
renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
109
|
+
renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
110
|
+
renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
111
|
+
ResourceLinkIcon?: ComponentType<{ className?: string }>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function IssueRow({
|
|
115
|
+
issue,
|
|
116
|
+
clusterLabel,
|
|
117
|
+
open,
|
|
118
|
+
onToggle,
|
|
119
|
+
resourceHref,
|
|
120
|
+
onResourceClick,
|
|
121
|
+
as = 'li',
|
|
122
|
+
className,
|
|
123
|
+
dimmed,
|
|
124
|
+
renderBadges,
|
|
125
|
+
renderMeta,
|
|
126
|
+
renderActions,
|
|
127
|
+
ResourceLinkIcon = ExternalLink,
|
|
128
|
+
}: IssueRowProps) {
|
|
108
129
|
const cluster = clusterLabel?.(issue);
|
|
109
130
|
const affected = affectedSummary(issue.affected);
|
|
110
131
|
const { headline } = issueMessageParts(issue);
|
|
111
132
|
const [renderDetails, setRenderDetails] = useState(open);
|
|
133
|
+
const Container = as;
|
|
134
|
+
const slotCtx = { issue, open };
|
|
112
135
|
|
|
113
136
|
useEffect(() => {
|
|
114
137
|
if (open) {
|
|
@@ -122,8 +145,12 @@ function IssueRow({
|
|
|
122
145
|
}, [open, renderDetails]);
|
|
123
146
|
|
|
124
147
|
return (
|
|
125
|
-
<
|
|
126
|
-
className=
|
|
148
|
+
<Container
|
|
149
|
+
className={[
|
|
150
|
+
'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
|
|
151
|
+
dimmed ? 'opacity-60' : '',
|
|
152
|
+
className ?? '',
|
|
153
|
+
].filter(Boolean).join(' ')}
|
|
127
154
|
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 72px' }}
|
|
128
155
|
>
|
|
129
156
|
{/* The whole header is the single toggle target — chevron is just the
|
|
@@ -149,6 +176,7 @@ function IssueRow({
|
|
|
149
176
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
150
177
|
<span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
|
|
151
178
|
<span className={`badge-sm shrink-0 self-center text-[10px] ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
179
|
+
{renderBadges?.(slotCtx)}
|
|
152
180
|
{/* The detector reason/message rides the title row so the most
|
|
153
181
|
useful triage signal is visible without expanding — it fills
|
|
154
182
|
the otherwise-empty band between the title and the severity
|
|
@@ -180,6 +208,7 @@ function IssueRow({
|
|
|
180
208
|
<span className="shrink-0 tabular-nums">{affected}</span>
|
|
181
209
|
</>
|
|
182
210
|
) : null}
|
|
211
|
+
{renderMeta?.(slotCtx)}
|
|
183
212
|
</div>
|
|
184
213
|
</div>
|
|
185
214
|
|
|
@@ -203,6 +232,7 @@ function IssueRow({
|
|
|
203
232
|
<span className={`badge-sm shrink-0 text-[10px] font-semibold ${ISSUE_SEVERITY_BADGE_CLASS[issue.severity]}`}>
|
|
204
233
|
{ISSUE_SEVERITY_LABEL[issue.severity]}
|
|
205
234
|
</span>
|
|
235
|
+
{renderActions?.(slotCtx)}
|
|
206
236
|
</div>
|
|
207
237
|
|
|
208
238
|
{renderDetails ? (
|
|
@@ -218,16 +248,16 @@ function IssueRow({
|
|
|
218
248
|
<div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
|
|
219
249
|
<div className="flex flex-col gap-4">
|
|
220
250
|
<Diagnosis issue={issue} />
|
|
221
|
-
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
251
|
+
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
222
252
|
<div className="border-t border-theme-border/70 pt-3">
|
|
223
|
-
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
253
|
+
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
224
254
|
</div>
|
|
225
255
|
</div>
|
|
226
256
|
</div>
|
|
227
257
|
</div>
|
|
228
258
|
</div>
|
|
229
259
|
) : null}
|
|
230
|
-
</
|
|
260
|
+
</Container>
|
|
231
261
|
);
|
|
232
262
|
}
|
|
233
263
|
|
|
@@ -316,10 +346,12 @@ function DiagnosticContext({
|
|
|
316
346
|
issue,
|
|
317
347
|
resourceHref,
|
|
318
348
|
onResourceClick,
|
|
349
|
+
ResourceLinkIcon,
|
|
319
350
|
}: {
|
|
320
351
|
issue: Issue;
|
|
321
352
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
322
353
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
354
|
+
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
323
355
|
}) {
|
|
324
356
|
const ctx = issue.diagnostic_context;
|
|
325
357
|
const facts = ctx?.facts?.filter((fact) => fact.message || fact.refs?.length || fact.related_issues?.length) ?? [];
|
|
@@ -347,6 +379,7 @@ function DiagnosticContext({
|
|
|
347
379
|
refForLink={memberRef(issue, related.ref)}
|
|
348
380
|
resourceHref={resourceHref}
|
|
349
381
|
onResourceClick={onResourceClick}
|
|
382
|
+
ResourceLinkIcon={ResourceLinkIcon}
|
|
350
383
|
/>
|
|
351
384
|
))}
|
|
352
385
|
</ul>
|
|
@@ -359,6 +392,7 @@ function DiagnosticContext({
|
|
|
359
392
|
refForLink={memberRef(issue, ref)}
|
|
360
393
|
resourceHref={resourceHref}
|
|
361
394
|
onResourceClick={onResourceClick}
|
|
395
|
+
ResourceLinkIcon={ResourceLinkIcon}
|
|
362
396
|
/>
|
|
363
397
|
))}
|
|
364
398
|
</ul>
|
|
@@ -424,10 +458,12 @@ function AffectedResources({
|
|
|
424
458
|
issue,
|
|
425
459
|
resourceHref,
|
|
426
460
|
onResourceClick,
|
|
461
|
+
ResourceLinkIcon,
|
|
427
462
|
}: {
|
|
428
463
|
issue: Issue;
|
|
429
464
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
430
465
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
466
|
+
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
431
467
|
}) {
|
|
432
468
|
const members = issue.members ?? [];
|
|
433
469
|
// count is the backend fan-out size (members, subject excluded — see
|
|
@@ -439,7 +475,7 @@ function AffectedResources({
|
|
|
439
475
|
first deep-link; members (the folded pods) follow. ResourceLine emits
|
|
440
476
|
an <li>, so it needs a list parent of its own. */}
|
|
441
477
|
<ul className="flex flex-col gap-px">
|
|
442
|
-
<ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
478
|
+
<ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
443
479
|
</ul>
|
|
444
480
|
{members.length > 0 && (
|
|
445
481
|
<>
|
|
@@ -453,6 +489,7 @@ function AffectedResources({
|
|
|
453
489
|
refForLink={memberRef(issue, m)}
|
|
454
490
|
resourceHref={resourceHref}
|
|
455
491
|
onResourceClick={onResourceClick}
|
|
492
|
+
ResourceLinkIcon={ResourceLinkIcon}
|
|
456
493
|
/>
|
|
457
494
|
))}
|
|
458
495
|
</ul>
|
|
@@ -472,11 +509,13 @@ function ResourceLine({
|
|
|
472
509
|
refForLink,
|
|
473
510
|
resourceHref,
|
|
474
511
|
onResourceClick,
|
|
512
|
+
ResourceLinkIcon,
|
|
475
513
|
}: {
|
|
476
514
|
label?: string;
|
|
477
515
|
refForLink: IssueResourceRef;
|
|
478
516
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
479
517
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
518
|
+
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
480
519
|
}) {
|
|
481
520
|
const r = refForLink;
|
|
482
521
|
const linkable = !!(onResourceClick || resourceHref);
|
|
@@ -488,7 +527,7 @@ function ResourceLine({
|
|
|
488
527
|
{r.namespace ? `${r.namespace} / ` : ''}
|
|
489
528
|
{r.name}
|
|
490
529
|
</span>
|
|
491
|
-
{linkable && <
|
|
530
|
+
{linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
|
|
492
531
|
</>
|
|
493
532
|
);
|
|
494
533
|
const cls = 'group/r flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60';
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// module-internal and don't collide at the top-level barrel with the Checks
|
|
3
3
|
// queue's identically-named helpers when both land. Issue-prefixed public
|
|
4
4
|
// names are safe to surface.
|
|
5
|
-
export { IssuesView } from './IssuesView';
|
|
6
|
-
export type { IssuesViewProps } from './IssuesView';
|
|
5
|
+
export { IssueRow, IssuesView } from './IssuesView';
|
|
6
|
+
export type { IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
|
|
7
7
|
export {
|
|
8
8
|
ISSUE_SEVERITIES,
|
|
9
9
|
ISSUE_SEVERITY_RANK,
|
|
@@ -28,6 +28,13 @@ describe('compareIssues', () => {
|
|
|
28
28
|
expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
it('orders direct startup blockers before generic problem rows at same severity', () => {
|
|
32
|
+
const generic = mk({ id: 'generic', source: 'problem', first_seen: '2026-05-01T00:00:00Z' })
|
|
33
|
+
const blocker = mk({ id: 'blocker', source: 'scheduling', first_seen: '2026-01-01T00:00:00Z' })
|
|
34
|
+
const missing = mk({ id: 'missing', source: 'missing_ref', first_seen: '2026-03-01T00:00:00Z' })
|
|
35
|
+
expect([generic, blocker, missing].sort(compareIssues).map((i) => i.id)).toEqual(['blocker', 'missing', 'generic'])
|
|
36
|
+
})
|
|
37
|
+
|
|
31
38
|
it('does NOT reshuffle same-severity rows when only last_seen changes (anti-churn)', () => {
|
|
32
39
|
// Two same-severity rows, same first_seen — order is the deterministic name tiebreak.
|
|
33
40
|
const a = mk({ id: 'id-a', name: 'a', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-01T00:00:00Z' })
|
|
@@ -24,6 +24,13 @@ export const ISSUE_SEVERITY_RANK: Record<IssueSeverity, number> = {
|
|
|
24
24
|
warning: 1,
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
const ISSUE_SOURCE_RANK: Record<string, number> = {
|
|
28
|
+
scheduling: 4,
|
|
29
|
+
missing_ref: 3,
|
|
30
|
+
condition: 2,
|
|
31
|
+
problem: 1,
|
|
32
|
+
};
|
|
33
|
+
|
|
27
34
|
export function isIssueSeverity(s: string): s is IssueSeverity {
|
|
28
35
|
return s === 'critical' || s === 'warning';
|
|
29
36
|
}
|
|
@@ -209,15 +216,18 @@ export function memberRef(issue: Issue, member: IssueResourceRef): IssueResource
|
|
|
209
216
|
|
|
210
217
|
/**
|
|
211
218
|
* compareIssues is the queue's stable sort order (extracted from IssuesView so
|
|
212
|
-
* it can be unit-tested). Severity first (critical before warning), then
|
|
213
|
-
*
|
|
214
|
-
* on every poll, so sorting by
|
|
215
|
-
*
|
|
216
|
-
*
|
|
219
|
+
* it can be unit-tested). Severity first (critical before warning), then
|
|
220
|
+
* direct-blocker source priority, then ONSET — first_seen DESC, deliberately
|
|
221
|
+
* NOT last_seen: last_seen bumps to compose-time on every poll, so sorting by
|
|
222
|
+
* it would reshuffle same-severity rows on each refetch. The remaining keys
|
|
223
|
+
* (cluster → namespace → name → id) are a fully deterministic tiebreak so the
|
|
224
|
+
* order never churns under auto-refresh.
|
|
217
225
|
*/
|
|
218
226
|
export function compareIssues(a: Issue, b: Issue): number {
|
|
219
227
|
const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
|
|
220
228
|
if (r !== 0) return r;
|
|
229
|
+
const sr = (ISSUE_SOURCE_RANK[b.source] ?? 0) - (ISSUE_SOURCE_RANK[a.source] ?? 0);
|
|
230
|
+
if (sr !== 0) return sr;
|
|
221
231
|
const fa = a.first_seen ?? '';
|
|
222
232
|
const fb = b.first_seen ?? '';
|
|
223
233
|
if (fa !== fb) return fb.localeCompare(fa);
|
|
@@ -90,18 +90,21 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
90
90
|
setFetchError(null)
|
|
91
91
|
try {
|
|
92
92
|
const result = await fetchAll({ container: selectedContainer || undefined, tailLines, sinceSeconds })
|
|
93
|
+
// Older backends marshal empty results as null rather than [].
|
|
94
|
+
const resultPods = result.pods ?? []
|
|
95
|
+
const resultLogs = result.logs ?? []
|
|
93
96
|
|
|
94
|
-
setPods(
|
|
97
|
+
setPods(resultPods)
|
|
95
98
|
|
|
96
|
-
if (!podsInitialized.current &&
|
|
99
|
+
if (!podsInitialized.current && resultPods.length > 0) {
|
|
97
100
|
podsInitialized.current = true
|
|
98
|
-
setSelectedPods(new Set(
|
|
101
|
+
setSelectedPods(new Set(resultPods.map(p => p.name)))
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
const indexByPod = new Map<string, number>()
|
|
102
|
-
|
|
105
|
+
resultPods.forEach((pod, i) => indexByPod.set(pod.name, i))
|
|
103
106
|
|
|
104
|
-
set(
|
|
107
|
+
set(resultLogs.map(log => ({
|
|
105
108
|
timestamp: log.timestamp,
|
|
106
109
|
content: log.content,
|
|
107
110
|
container: log.container,
|
|
@@ -174,12 +174,13 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
|
|
|
174
174
|
|
|
175
175
|
return (
|
|
176
176
|
<>
|
|
177
|
-
{/* Scaling in progress banner
|
|
177
|
+
{/* Scaling in progress banner — amber: replicas short of desired is an
|
|
178
|
+
attention state, not an info note (it may be a stuck rollout). */}
|
|
178
179
|
{(scaledTo !== null || progressMessage) && !hasProblems && (
|
|
179
|
-
<div className="mb-4 p-3 bg-
|
|
180
|
+
<div className="mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
|
|
180
181
|
<div className="flex items-center gap-2">
|
|
181
|
-
<Loader2 className="w-4 h-4 text-
|
|
182
|
-
<div className="text-sm text-
|
|
182
|
+
<Loader2 className="w-4 h-4 text-amber-500 animate-spin shrink-0" />
|
|
183
|
+
<div className="text-sm text-amber-700 dark:text-amber-300">
|
|
183
184
|
{progressMessage || `Scaling to ${scaledTo} replicas...`}
|
|
184
185
|
</div>
|
|
185
186
|
</div>
|
|
@@ -30,6 +30,8 @@ export interface DetailShellProps<TId extends string = string> {
|
|
|
30
30
|
onTabChange: (id: TId) => void
|
|
31
31
|
tabStripEnd?: ReactNode
|
|
32
32
|
overlay?: ReactNode
|
|
33
|
+
/** Hide breadcrumb/identity/header actions when a host page already owns that chrome. */
|
|
34
|
+
compactHeader?: boolean
|
|
33
35
|
children: ReactNode
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -44,6 +46,7 @@ export function DetailShell<TId extends string = string>({
|
|
|
44
46
|
onTabChange,
|
|
45
47
|
tabStripEnd,
|
|
46
48
|
overlay,
|
|
49
|
+
compactHeader = false,
|
|
47
50
|
children,
|
|
48
51
|
}: DetailShellProps<TId>) {
|
|
49
52
|
const visibleTabs = tabs.filter((t) => !t.hidden)
|
|
@@ -52,15 +55,19 @@ export function DetailShell<TId extends string = string>({
|
|
|
52
55
|
<div className="flex flex-col h-full w-full bg-theme-surface">
|
|
53
56
|
{/* Header */}
|
|
54
57
|
<div className="shrink-0 border-b border-theme-border bg-theme-surface">
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
{!compactHeader && (
|
|
59
|
+
<>
|
|
60
|
+
{breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
|
|
61
|
+
<div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
|
|
62
|
+
{nav}
|
|
63
|
+
<div className="flex-1 min-w-0">{identity}</div>
|
|
64
|
+
{headerActions}
|
|
65
|
+
</div>
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
61
68
|
|
|
62
69
|
{/* Tabs (left) + scope controls / actions (right) */}
|
|
63
|
-
<div className=
|
|
70
|
+
<div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
|
|
64
71
|
<div className="flex gap-1" role="tablist">
|
|
65
72
|
{visibleTabs.map((t) => (
|
|
66
73
|
<DetailShellTabButton key={t.id} active={activeTab === t.id} onClick={() => onTabChange(t.id)}>
|
|
@@ -66,19 +66,27 @@ function formatSaveError(error: string): { summary: string; details?: string } {
|
|
|
66
66
|
const errorPart = parts[1]?.trim() || ''
|
|
67
67
|
|
|
68
68
|
if (errorPart.includes('Forbidden:')) {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
69
|
+
const forbiddenAt = errorPart.indexOf(': Forbidden:')
|
|
70
|
+
if (forbiddenAt > 0) {
|
|
71
|
+
const target = errorPart.slice(0, forbiddenAt).trim()
|
|
72
|
+
const messageStart = forbiddenAt + ': Forbidden:'.length
|
|
73
|
+
const dotAt = errorPart.indexOf('.', messageStart)
|
|
74
|
+
const braceAt = errorPart.indexOf('{', messageStart)
|
|
75
|
+
const endCandidates = [dotAt, braceAt].filter((i) => i >= 0)
|
|
76
|
+
const messageEnd = endCandidates.length ? Math.min(...endCandidates) : errorPart.length
|
|
77
|
+
const message = errorPart.slice(messageStart, messageEnd).trim()
|
|
71
78
|
return {
|
|
72
|
-
summary: `Cannot update ${
|
|
79
|
+
summary: `Cannot update ${target}: ${message}`,
|
|
73
80
|
details: error.length > 200 ? error : undefined
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
const
|
|
79
|
-
|
|
85
|
+
const braceAt = errorPart.indexOf('{')
|
|
86
|
+
const summary = (braceAt >= 0 ? errorPart.slice(0, braceAt) : errorPart).trim()
|
|
87
|
+
if (summary) {
|
|
80
88
|
return {
|
|
81
|
-
summary
|
|
89
|
+
summary,
|
|
82
90
|
details: error.length > 200 ? error : undefined
|
|
83
91
|
}
|
|
84
92
|
}
|
|
@@ -111,6 +119,8 @@ interface EditableYamlViewProps {
|
|
|
111
119
|
data: any
|
|
112
120
|
onCopy: (text: string) => void
|
|
113
121
|
copied: boolean
|
|
122
|
+
/** Hide edit affordances when the host surface is read-only. */
|
|
123
|
+
readOnly?: boolean
|
|
114
124
|
/** Called after a successful save so the parent can refetch */
|
|
115
125
|
onSaved?: () => void
|
|
116
126
|
/** Save handler — injected by the platform wrapper */
|
|
@@ -128,13 +138,13 @@ interface EditableYamlViewProps {
|
|
|
128
138
|
onDownload?: (content: string, mime: string, filename: string) => void
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
|
|
141
|
+
export function EditableYamlView({ resource, data, onCopy, copied, readOnly = false, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
|
|
132
142
|
const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
|
|
133
143
|
|
|
134
144
|
// Restore draft from sessionStorage (e.g., after session-expiry redirect).
|
|
135
145
|
// All sessionStorage calls are wrapped in try-catch — storage can throw
|
|
136
146
|
// QuotaExceededError or be blocked by browser security policies.
|
|
137
|
-
const savedDraft = useRef(safeSessionGet(draftKey))
|
|
147
|
+
const savedDraft = useRef(readOnly ? null : safeSessionGet(draftKey))
|
|
138
148
|
const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
|
|
139
149
|
const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
|
|
140
150
|
const [yamlErrors, setYamlErrors] = useState<string[]>([])
|
|
@@ -153,12 +163,19 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
|
|
|
153
163
|
|
|
154
164
|
// Autosave draft to sessionStorage while editing (best-effort)
|
|
155
165
|
useEffect(() => {
|
|
166
|
+
if (readOnly) {
|
|
167
|
+
setIsEditing(false)
|
|
168
|
+
setEditedYaml('')
|
|
169
|
+
setYamlErrors([])
|
|
170
|
+
safeSessionRemove(draftKey)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
156
173
|
if (isEditing && editedYaml) {
|
|
157
174
|
safeSessionSet(draftKey, editedYaml)
|
|
158
175
|
} else {
|
|
159
176
|
safeSessionRemove(draftKey)
|
|
160
177
|
}
|
|
161
|
-
}, [isEditing, editedYaml, draftKey])
|
|
178
|
+
}, [isEditing, editedYaml, draftKey, readOnly])
|
|
162
179
|
|
|
163
180
|
const handleDownload = useCallback(() => {
|
|
164
181
|
const yaml = resourceToYaml(data)
|
|
@@ -170,10 +187,11 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
|
|
|
170
187
|
}, [data, resource.kind, resource.name, onDownload])
|
|
171
188
|
|
|
172
189
|
const handleStartEdit = useCallback(() => {
|
|
190
|
+
if (readOnly) return
|
|
173
191
|
setEditedYaml(resourceToYaml(data))
|
|
174
192
|
setYamlErrors([])
|
|
175
193
|
setIsEditing(true)
|
|
176
|
-
}, [data])
|
|
194
|
+
}, [data, readOnly])
|
|
177
195
|
|
|
178
196
|
const handleCancelEdit = useCallback(() => {
|
|
179
197
|
setIsEditing(false)
|
|
@@ -345,13 +363,15 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
|
|
|
345
363
|
<div className="flex items-center justify-between mb-2">
|
|
346
364
|
<span className="text-sm font-medium text-theme-text-secondary">YAML</span>
|
|
347
365
|
<div className="flex items-center gap-2">
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
366
|
+
{!readOnly && (
|
|
367
|
+
<button
|
|
368
|
+
onClick={handleStartEdit}
|
|
369
|
+
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:text-blue-300 hover:bg-theme-elevated rounded"
|
|
370
|
+
>
|
|
371
|
+
<Pencil className="w-3.5 h-3.5" />
|
|
372
|
+
Edit
|
|
373
|
+
</button>
|
|
374
|
+
)}
|
|
355
375
|
<button
|
|
356
376
|
onClick={() => onCopy(yamlContent)}
|
|
357
377
|
className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
@@ -23,7 +23,7 @@ import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDele
|
|
|
23
23
|
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
24
24
|
import { DialogPortal } from '../ui/DialogPortal'
|
|
25
25
|
import type { SelectedResource, WorkloadRevision } from '../../types'
|
|
26
|
-
import {
|
|
26
|
+
import { displayKindName } from '../ui/drawer-components'
|
|
27
27
|
import { getDefaultContainerName } from '../resources/resource-utils'
|
|
28
28
|
|
|
29
29
|
// ============================================================================
|
|
@@ -517,20 +517,20 @@ export function ResourceActionsBar({
|
|
|
517
517
|
<Tooltip
|
|
518
518
|
content={
|
|
519
519
|
onCompareTo && onCompareAcrossClusters
|
|
520
|
-
? `Compare ${
|
|
520
|
+
? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
|
|
521
521
|
: onCompareAcrossClusters
|
|
522
522
|
? `Compare across clusters`
|
|
523
|
-
: `Compare to another ${
|
|
523
|
+
: `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
|
|
524
524
|
}
|
|
525
525
|
>
|
|
526
526
|
<button
|
|
527
527
|
onClick={onCompareTo ?? onCompareAcrossClusters}
|
|
528
528
|
aria-label={
|
|
529
529
|
onCompareTo && onCompareAcrossClusters
|
|
530
|
-
? `Compare ${
|
|
530
|
+
? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
|
|
531
531
|
: onCompareAcrossClusters
|
|
532
532
|
? `Compare across clusters`
|
|
533
|
-
: `Compare to another ${
|
|
533
|
+
: `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
|
|
534
534
|
}
|
|
535
535
|
className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-theme-text-primary hover:bg-theme-elevated transition-colors flex items-center"
|
|
536
536
|
>
|
|
@@ -577,7 +577,7 @@ export function ResourceActionsBar({
|
|
|
577
577
|
onClose={() => setShowDeleteConfirm(false)}
|
|
578
578
|
onConfirm={handleDeleteConfirm}
|
|
579
579
|
resourceName={resource.name}
|
|
580
|
-
resourceKind={
|
|
580
|
+
resourceKind={displayKindName(resource.kind, data?.kind)}
|
|
581
581
|
namespaceName={resource.namespace}
|
|
582
582
|
isLoading={isDeleting ?? false}
|
|
583
583
|
cascadeDependents={cascadeDependents}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { useState, useMemo,
|
|
1
|
+
import { useState, useMemo, useEffect } from 'react'
|
|
2
2
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
3
3
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
4
|
+
import { SearchBox } from '../ui/SearchBox'
|
|
4
5
|
import {
|
|
5
6
|
AlertCircle,
|
|
6
7
|
CheckCircle,
|
|
7
8
|
Clock,
|
|
8
|
-
Search,
|
|
9
9
|
RefreshCw,
|
|
10
10
|
ChevronRight,
|
|
11
11
|
Filter,
|
|
@@ -24,7 +24,6 @@ import { ResourceRefBadge } from '../ui/drawer-components'
|
|
|
24
24
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
25
25
|
import { kindToPlural, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
|
|
26
26
|
import { pluralize } from '../../utils/pluralize'
|
|
27
|
-
import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
|
|
28
27
|
|
|
29
28
|
/** Format resource age (e.g., "3d", "5h", "10m") */
|
|
30
29
|
function formatResourceAge(createdAt: string): string {
|
|
@@ -87,29 +86,11 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
87
86
|
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
|
|
88
87
|
const [kindFilter, setKindFilter] = useState<string>('')
|
|
89
88
|
const [expandedItem, setExpandedItem] = useState<string | null>(null)
|
|
90
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
91
89
|
|
|
92
90
|
useEffect(() => {
|
|
93
91
|
onQueryChange?.({ timeRange, kind: kindFilter || undefined })
|
|
94
92
|
}, [timeRange, kindFilter, onQueryChange])
|
|
95
93
|
|
|
96
|
-
// Keyboard shortcut: / to focus search
|
|
97
|
-
useRegisterShortcut({
|
|
98
|
-
id: 'timeline-list-search',
|
|
99
|
-
keys: '/',
|
|
100
|
-
description: 'Focus search',
|
|
101
|
-
category: 'Search',
|
|
102
|
-
scope: 'timeline',
|
|
103
|
-
handler: () => searchInputRef.current?.focus(),
|
|
104
|
-
})
|
|
105
|
-
useRegisterShortcut({
|
|
106
|
-
id: 'timeline-list-escape',
|
|
107
|
-
keys: 'Escape',
|
|
108
|
-
description: 'Blur search',
|
|
109
|
-
category: 'Search',
|
|
110
|
-
scope: 'timeline',
|
|
111
|
-
handler: () => searchInputRef.current?.blur(),
|
|
112
|
-
})
|
|
113
94
|
|
|
114
95
|
const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(onRefresh ?? (() => {}))
|
|
115
96
|
|
|
@@ -278,17 +259,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
278
259
|
{/* Toolbar */}
|
|
279
260
|
<div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 flex-wrap">
|
|
280
261
|
{/* Search */}
|
|
281
|
-
<
|
|
282
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
283
|
-
<input
|
|
284
|
-
ref={searchInputRef}
|
|
285
|
-
type="text"
|
|
286
|
-
placeholder="Search... (press /)"
|
|
287
|
-
value={searchTerm}
|
|
288
|
-
onChange={(e) => setSearchTerm(e.target.value)}
|
|
289
|
-
className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
290
|
-
/>
|
|
291
|
-
</div>
|
|
262
|
+
<SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="timeline-list-search" className="flex-1 min-w-[200px] max-w-md" />
|
|
292
263
|
|
|
293
264
|
{/* Activity type filter */}
|
|
294
265
|
<div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
ZoomIn,
|
|
8
8
|
ZoomOut,
|
|
9
9
|
ChevronRight,
|
|
10
|
-
Search,
|
|
11
10
|
X,
|
|
12
11
|
List,
|
|
13
12
|
GanttChart,
|
|
@@ -27,6 +26,7 @@ import type { TimelineEvent, Topology } from '../../types'
|
|
|
27
26
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
28
27
|
import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
|
|
29
28
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
29
|
+
import { SearchBox } from '../ui/SearchBox'
|
|
30
30
|
import { pluralize } from '../../utils/pluralize'
|
|
31
31
|
import { gitOpsRouteForKind } from '../../utils/gitops-route'
|
|
32
32
|
import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
|
|
@@ -194,7 +194,6 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
194
194
|
onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
|
|
195
195
|
}, [onNavigatePath, onResourceClick])
|
|
196
196
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
197
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
198
197
|
const [zoom, setZoom] = useState(1)
|
|
199
198
|
const [panOffset, setPanOffset] = useState(0)
|
|
200
199
|
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null)
|
|
@@ -241,23 +240,14 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
241
240
|
}, [events, hasAutoZoomed])
|
|
242
241
|
|
|
243
242
|
// Keyboard shortcuts
|
|
244
|
-
useRegisterShortcut({
|
|
245
|
-
id: 'swimlane-search',
|
|
246
|
-
keys: '/',
|
|
247
|
-
description: 'Focus search',
|
|
248
|
-
category: 'Search',
|
|
249
|
-
scope: 'timeline',
|
|
250
|
-
handler: () => searchInputRef.current?.focus(),
|
|
251
|
-
})
|
|
252
243
|
useRegisterShortcut({
|
|
253
244
|
id: 'swimlane-escape',
|
|
254
245
|
keys: 'Escape',
|
|
255
|
-
description: 'Close detail
|
|
246
|
+
description: 'Close event detail',
|
|
256
247
|
category: 'Timeline',
|
|
257
248
|
scope: 'timeline',
|
|
258
249
|
handler: () => {
|
|
259
250
|
if (selectedEvent) setSelectedEvent(null)
|
|
260
|
-
else searchInputRef.current?.blur()
|
|
261
251
|
},
|
|
262
252
|
})
|
|
263
253
|
|
|
@@ -467,25 +457,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
467
457
|
<div className="flex items-center justify-between px-4 py-2">
|
|
468
458
|
<div className="flex items-center gap-4">
|
|
469
459
|
{/* Search */}
|
|
470
|
-
<
|
|
471
|
-
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
472
|
-
<input
|
|
473
|
-
ref={searchInputRef}
|
|
474
|
-
type="text"
|
|
475
|
-
value={searchTerm}
|
|
476
|
-
onChange={(e) => setSearchTerm(e.target.value)}
|
|
477
|
-
placeholder="Search... (press /)"
|
|
478
|
-
className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
479
|
-
/>
|
|
480
|
-
{searchTerm && (
|
|
481
|
-
<button
|
|
482
|
-
onClick={() => setSearchTerm('')}
|
|
483
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-primary"
|
|
484
|
-
>
|
|
485
|
-
<X className="w-4 h-4" />
|
|
486
|
-
</button>
|
|
487
|
-
)}
|
|
488
|
-
</div>
|
|
460
|
+
<SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="swimlane-search" className="w-80" />
|
|
489
461
|
{/* Zoom controls */}
|
|
490
462
|
<div className="flex items-center gap-2">
|
|
491
463
|
<button
|