@skyhook-io/k8s-ui 1.7.6 → 1.7.8
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 +6 -1
- package/src/components/charts/PrometheusChartsView.tsx +233 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/checks/ChecksView.tsx +5 -1
- package/src/components/gitops/GitOpsDetailLayout.tsx +4 -4
- package/src/components/gitops/GitOpsTableView.tsx +203 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
- package/src/components/issues/IssuesView.tsx +352 -0
- package/src/components/issues/index.ts +24 -0
- package/src/components/issues/issues.test.ts +100 -0
- package/src/components/issues/severity.ts +125 -0
- package/src/components/issues/types.ts +194 -0
- package/src/components/resources/ResourcesView.tsx +27 -25
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
- package/src/components/resources/renderers/PodRenderer.tsx +7 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
- package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
- package/src/components/resources/resource-utils-argo.ts +18 -0
- package/src/components/shared/DetailShell.tsx +107 -0
- package/src/components/shared/ManagedByChip.tsx +149 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/topology/K8sResourceNode.tsx +60 -60
- package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
- package/src/components/topology/TopologyGraph.tsx +168 -52
- package/src/components/topology/TopologySearch.tsx +17 -8
- package/src/components/topology/topology-search-match.test.ts +1 -1
- package/src/components/topology/topology.css +18 -0
- package/src/components/ui/RowActionMenu.tsx +149 -0
- package/src/components/ui/Tooltip.tsx +37 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +118 -112
- package/src/index.ts +5 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +3 -2
- package/src/utils/env-from.ts +3 -0
- package/src/utils/git-provider-urls.test.ts +348 -0
- package/src/utils/git-provider-urls.ts +142 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/replica-scalers.ts +9 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.8",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"./src/components/gitops/*.tsx",
|
|
32
32
|
"./src/components/gitops/*.ts"
|
|
33
33
|
],
|
|
34
|
+
"./components/issues": "./src/components/issues/index.ts",
|
|
35
|
+
"./components/issues/*": [
|
|
36
|
+
"./src/components/issues/*.tsx",
|
|
37
|
+
"./src/components/issues/*.ts"
|
|
38
|
+
],
|
|
34
39
|
"./components/timeline/*": [
|
|
35
40
|
"./src/components/timeline/*.tsx",
|
|
36
41
|
"./src/components/timeline/*.ts"
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
import { BarChart3, Wifi, WifiOff, Loader2 } from 'lucide-react'
|
|
3
|
+
import { AreaChart } from './AreaChart'
|
|
4
|
+
import { MetricsSummary } from './MetricsSummary'
|
|
5
|
+
import { SeriesLegend } from './SeriesLegend'
|
|
6
|
+
import type { TimeSeries, ReferenceLine } from './types'
|
|
7
|
+
|
|
8
|
+
// Pure presentational Prometheus charts view, shared between radar's WorkloadView
|
|
9
|
+
// and Radar Hub's Application metrics tab. It owns NO data fetching — the host
|
|
10
|
+
// passes status, the selected category/range, the metrics result, and an
|
|
11
|
+
// onConnect callback. radar/web injects its global-apiBase hooks; the hub injects
|
|
12
|
+
// per-cluster tunnel fetches. Built on the AreaChart/MetricsSummary/SeriesLegend
|
|
13
|
+
// primitives already in this package.
|
|
14
|
+
|
|
15
|
+
export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
|
|
16
|
+
export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
|
|
17
|
+
|
|
18
|
+
export interface MetricCategoryDef {
|
|
19
|
+
key: PrometheusMetricCategory
|
|
20
|
+
label: string
|
|
21
|
+
color: string // tailwind text class for the summary's current value
|
|
22
|
+
chartColor: string // hex for the SVG line
|
|
23
|
+
fillColor: string // hex + alpha for the SVG fill
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const WORKLOAD_METRIC_CATEGORIES: MetricCategoryDef[] = [
|
|
27
|
+
{ key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
|
|
28
|
+
{ key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
|
|
29
|
+
{ key: 'network_rx', label: 'Net RX', color: 'text-emerald-400', chartColor: '#34d399', fillColor: '#34d39922' },
|
|
30
|
+
{ key: 'network_tx', label: 'Net TX', color: 'text-orange-400', chartColor: '#fb923c', fillColor: '#fb923c22' },
|
|
31
|
+
{ key: 'filesystem', label: 'Disk I/O', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export const NODE_METRIC_CATEGORIES: MetricCategoryDef[] = [
|
|
35
|
+
{ key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
|
|
36
|
+
{ key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
|
|
37
|
+
{ key: 'filesystem', label: 'Disk', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
export const METRIC_TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
|
|
41
|
+
{ value: '10m', label: '10m' },
|
|
42
|
+
{ value: '30m', label: '30m' },
|
|
43
|
+
{ value: '1h', label: '1h' },
|
|
44
|
+
{ value: '3h', label: '3h' },
|
|
45
|
+
{ value: '6h', label: '6h' },
|
|
46
|
+
{ value: '12h', label: '12h' },
|
|
47
|
+
{ value: '24h', label: '24h' },
|
|
48
|
+
{ value: '7d', label: '7d' },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const SUPPORTED_KINDS = new Set([
|
|
52
|
+
'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
export interface PrometheusResourceMetricsResult {
|
|
56
|
+
unit: string
|
|
57
|
+
result?: { series?: TimeSeries[] }
|
|
58
|
+
query?: string
|
|
59
|
+
hint?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PrometheusChartsViewProps {
|
|
63
|
+
kind: string
|
|
64
|
+
/** When false (embedded in an Overview), hide entirely when not connected or no data. */
|
|
65
|
+
showEmptyState?: boolean
|
|
66
|
+
// Connection status (host-provided)
|
|
67
|
+
statusLoading: boolean
|
|
68
|
+
isConnected: boolean
|
|
69
|
+
statusError?: string
|
|
70
|
+
onConnect: () => void
|
|
71
|
+
connecting: boolean
|
|
72
|
+
// Selection (controlled by host)
|
|
73
|
+
category: PrometheusMetricCategory
|
|
74
|
+
onCategoryChange: (c: PrometheusMetricCategory) => void
|
|
75
|
+
range: PrometheusTimeRange
|
|
76
|
+
onRangeChange: (r: PrometheusTimeRange) => void
|
|
77
|
+
// Metrics (host-fetched)
|
|
78
|
+
metrics?: PrometheusResourceMetricsResult
|
|
79
|
+
metricsLoading: boolean
|
|
80
|
+
metricsError?: Error | null
|
|
81
|
+
referenceLines?: ReferenceLine[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function PrometheusChartsView({
|
|
85
|
+
kind,
|
|
86
|
+
showEmptyState = false,
|
|
87
|
+
statusLoading,
|
|
88
|
+
isConnected,
|
|
89
|
+
statusError,
|
|
90
|
+
onConnect,
|
|
91
|
+
connecting,
|
|
92
|
+
category,
|
|
93
|
+
onCategoryChange,
|
|
94
|
+
range,
|
|
95
|
+
onRangeChange,
|
|
96
|
+
metrics,
|
|
97
|
+
metricsLoading,
|
|
98
|
+
metricsError,
|
|
99
|
+
referenceLines,
|
|
100
|
+
}: PrometheusChartsViewProps) {
|
|
101
|
+
const categories = kind === 'Node' ? NODE_METRIC_CATEGORIES : WORKLOAD_METRIC_CATEGORIES
|
|
102
|
+
const isSupported = SUPPORTED_KINDS.has(kind)
|
|
103
|
+
const activeCategoryDef = categories.find((c) => c.key === category) || categories[0]
|
|
104
|
+
const series = metrics?.result?.series ?? []
|
|
105
|
+
|
|
106
|
+
if (!isSupported) return null
|
|
107
|
+
|
|
108
|
+
if (statusLoading) {
|
|
109
|
+
if (!showEmptyState) return null
|
|
110
|
+
return (
|
|
111
|
+
<div className="flex items-center justify-center py-12 text-theme-text-tertiary">
|
|
112
|
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
113
|
+
Checking Prometheus availability...
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!showEmptyState) {
|
|
119
|
+
if (!isConnected) return null
|
|
120
|
+
if (!metricsLoading && !metricsError && !series.length) return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!isConnected) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
|
126
|
+
<WifiOff className="h-10 w-10 text-theme-text-tertiary" />
|
|
127
|
+
<div className="text-center">
|
|
128
|
+
<p className="mb-1 text-sm text-theme-text-secondary">Prometheus not connected</p>
|
|
129
|
+
<p className="mb-4 text-xs text-theme-text-tertiary">
|
|
130
|
+
{statusError || 'Connect to view historical CPU, memory, and network metrics'}
|
|
131
|
+
</p>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={onConnect}
|
|
135
|
+
disabled={connecting}
|
|
136
|
+
className="btn-brand inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
|
137
|
+
>
|
|
138
|
+
{connecting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
|
|
139
|
+
Discover Prometheus
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex h-full flex-col">
|
|
148
|
+
{/* Toolbar */}
|
|
149
|
+
<div className="flex shrink-0 items-center justify-between border-b border-theme-border bg-theme-surface/50 px-4 py-2.5">
|
|
150
|
+
<div className="flex items-center gap-1">
|
|
151
|
+
<BarChart3 className="mr-2 h-4 w-4 text-theme-text-tertiary" />
|
|
152
|
+
{categories.map((cat) => (
|
|
153
|
+
<button
|
|
154
|
+
key={cat.key}
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => onCategoryChange(cat.key)}
|
|
157
|
+
className={clsx(
|
|
158
|
+
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
|
159
|
+
category === cat.key
|
|
160
|
+
? 'bg-theme-elevated text-theme-text-primary shadow-sm'
|
|
161
|
+
: 'text-theme-text-tertiary hover:bg-theme-elevated/50 hover:text-theme-text-secondary',
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{cat.label}
|
|
165
|
+
</button>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
<select
|
|
169
|
+
value={range}
|
|
170
|
+
onChange={(e) => onRangeChange(e.target.value as PrometheusTimeRange)}
|
|
171
|
+
className="rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
|
172
|
+
>
|
|
173
|
+
{METRIC_TIME_RANGES.map((tr) => (
|
|
174
|
+
<option key={tr.value} value={tr.value}>
|
|
175
|
+
{tr.label}
|
|
176
|
+
</option>
|
|
177
|
+
))}
|
|
178
|
+
</select>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Chart area */}
|
|
182
|
+
<div className="min-h-[280px] flex-1 p-4">
|
|
183
|
+
{metricsLoading ? (
|
|
184
|
+
<div className="flex min-h-[240px] items-center justify-center text-theme-text-tertiary">
|
|
185
|
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
186
|
+
Loading metrics...
|
|
187
|
+
</div>
|
|
188
|
+
) : metricsError ? (
|
|
189
|
+
<div className="flex h-full items-center justify-center text-sm text-red-400">
|
|
190
|
+
Failed to load metrics: {(metricsError as Error).message}
|
|
191
|
+
</div>
|
|
192
|
+
) : series.length ? (
|
|
193
|
+
<div className="flex h-full flex-col gap-4">
|
|
194
|
+
<MetricsSummary series={series} unit={metrics!.unit} currentColorClass={activeCategoryDef.color} />
|
|
195
|
+
<div className="min-h-0 flex-1">
|
|
196
|
+
<AreaChart
|
|
197
|
+
series={series}
|
|
198
|
+
color={activeCategoryDef.chartColor}
|
|
199
|
+
fillColor={activeCategoryDef.fillColor}
|
|
200
|
+
unit={metrics!.unit}
|
|
201
|
+
referenceLines={referenceLines}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
{series.length > 1 && <SeriesLegend series={series} color={activeCategoryDef.chartColor} />}
|
|
205
|
+
</div>
|
|
206
|
+
) : (
|
|
207
|
+
<div className="flex h-full flex-col items-center justify-center text-theme-text-tertiary">
|
|
208
|
+
<BarChart3 className="mb-2 h-8 w-8 opacity-40" />
|
|
209
|
+
<p className="text-sm">No data for this time range</p>
|
|
210
|
+
<p className="mt-1 text-xs text-theme-text-quaternary">
|
|
211
|
+
Try a different time range or check that metrics are being collected
|
|
212
|
+
</p>
|
|
213
|
+
{metrics?.hint && (
|
|
214
|
+
<p className="mt-3 w-full max-w-lg rounded border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-700 dark:text-yellow-400">
|
|
215
|
+
{metrics.hint}
|
|
216
|
+
</p>
|
|
217
|
+
)}
|
|
218
|
+
{metrics?.query && (
|
|
219
|
+
<details className="mt-3 w-full max-w-lg text-left">
|
|
220
|
+
<summary className="cursor-pointer text-xs text-theme-text-quaternary hover:text-theme-text-tertiary">
|
|
221
|
+
Diagnostics: show PromQL query
|
|
222
|
+
</summary>
|
|
223
|
+
<div className="mt-2 break-all rounded border border-theme-border bg-theme-base p-2 font-mono text-xs text-theme-text-secondary">
|
|
224
|
+
{metrics.query}
|
|
225
|
+
</div>
|
|
226
|
+
</details>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
export { AreaChart } from './AreaChart'
|
|
2
2
|
export { MetricsSummary } from './MetricsSummary'
|
|
3
3
|
export { SeriesLegend } from './SeriesLegend'
|
|
4
|
+
export {
|
|
5
|
+
PrometheusChartsView,
|
|
6
|
+
WORKLOAD_METRIC_CATEGORIES,
|
|
7
|
+
NODE_METRIC_CATEGORIES,
|
|
8
|
+
METRIC_TIME_RANGES,
|
|
9
|
+
} from './PrometheusChartsView'
|
|
10
|
+
export type {
|
|
11
|
+
PrometheusChartsViewProps,
|
|
12
|
+
PrometheusMetricCategory,
|
|
13
|
+
PrometheusTimeRange,
|
|
14
|
+
MetricCategoryDef,
|
|
15
|
+
PrometheusResourceMetricsResult,
|
|
16
|
+
} from './PrometheusChartsView'
|
|
4
17
|
export { SERIES_COLORS, seriesColor, seriesFill, computeShortLabels } from './colors'
|
|
5
18
|
export { formatMetricValue, formatTimestamp } from './format'
|
|
6
19
|
export { computeSaturation } from './saturation'
|
|
@@ -703,7 +703,11 @@ function FindingLine({
|
|
|
703
703
|
{body}
|
|
704
704
|
</button>
|
|
705
705
|
) : resourceHref ? (
|
|
706
|
-
|
|
706
|
+
// Opens in a new tab to match the ExternalLink glyph in `body`. For the
|
|
707
|
+
// Hub fleet view this href crosses the Hub→Cluster SPA boundary (a full
|
|
708
|
+
// document nav); a new tab keeps the Checks queue intact and avoids the
|
|
709
|
+
// cross-tree browser-Back dead-end (SKY-931).
|
|
710
|
+
<a href={resourceHref(r)} target="_blank" rel="noreferrer" className={cls}>
|
|
707
711
|
{body}
|
|
708
712
|
</a>
|
|
709
713
|
) : (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, type ComponentType, type ReactNode } from 'react'
|
|
2
|
-
import { ChevronDown, ChevronRight, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw,
|
|
2
|
+
import { ArrowDownUp, ChevronDown, ChevronRight, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, Settings, Trash2, XCircle, Zap } from 'lucide-react'
|
|
3
3
|
|
|
4
4
|
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
5
5
|
import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
|
|
@@ -340,7 +340,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
|
340
340
|
<ActionButton
|
|
341
341
|
label="Sync…"
|
|
342
342
|
description="Apply manifests from Git to the cluster. Opens an options dialog (prune, dry-run, revision)."
|
|
343
|
-
icon={
|
|
343
|
+
icon={ArrowDownUp}
|
|
344
344
|
loading={argo.syncing}
|
|
345
345
|
onClick={argo.onSyncRequested}
|
|
346
346
|
disabled={effectiveSuspended || terminating}
|
|
@@ -350,14 +350,14 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
|
350
350
|
<ActionButton
|
|
351
351
|
label="Refresh"
|
|
352
352
|
description="Re-check Git for new commits and recompute sync status. Doesn't apply anything."
|
|
353
|
-
icon={
|
|
353
|
+
icon={RefreshCw}
|
|
354
354
|
loading={argo.refreshing && argo.refreshingKind === 'normal'}
|
|
355
355
|
onClick={() => argo.onRefresh('normal')}
|
|
356
356
|
/>
|
|
357
357
|
<ActionButton
|
|
358
358
|
label="Hard refresh"
|
|
359
359
|
description="Like Refresh, but also bypasses Argo's manifest cache (re-renders Helm/Kustomize)."
|
|
360
|
-
icon={
|
|
360
|
+
icon={Zap}
|
|
361
361
|
loading={argo.refreshing && argo.refreshingKind === 'hard'}
|
|
362
362
|
onClick={() => argo.onRefresh('hard')}
|
|
363
363
|
/>
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType,
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import {
|
|
4
4
|
AlertTriangle,
|
|
5
|
+
ArrowDownUp,
|
|
5
6
|
CheckCircle2,
|
|
6
7
|
CircleAlert,
|
|
7
8
|
CircleDot,
|
|
@@ -10,16 +11,23 @@ import {
|
|
|
10
11
|
LayoutGrid,
|
|
11
12
|
List,
|
|
12
13
|
Loader2,
|
|
14
|
+
Pause,
|
|
15
|
+
Play,
|
|
13
16
|
RefreshCw,
|
|
14
17
|
RotateCcw,
|
|
18
|
+
RotateCw,
|
|
15
19
|
Search,
|
|
20
|
+
Square,
|
|
16
21
|
Tag,
|
|
17
22
|
Trash2,
|
|
23
|
+
Zap,
|
|
18
24
|
} from 'lucide-react'
|
|
19
25
|
|
|
20
26
|
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
21
27
|
import { Tooltip } from '../ui/Tooltip'
|
|
28
|
+
import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
|
|
22
29
|
import { getGitOpsResourceStatus } from './detail-helpers'
|
|
30
|
+
import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
|
|
23
31
|
import { toggleSet } from './GitOpsGraphFilterRail'
|
|
24
32
|
import { parseContextName } from '../../utils/context-name'
|
|
25
33
|
|
|
@@ -54,6 +62,21 @@ export type GitOpsMode = 'applications' | 'sources' | 'projects' | 'alerts'
|
|
|
54
62
|
export type GitOpsViewMode = 'table' | 'tiles'
|
|
55
63
|
export type SortKey = 'name' | 'health' | 'sync' | 'lastSync' | 'project'
|
|
56
64
|
|
|
65
|
+
// Row-level actions surfaced from the table's three-dot menu. The set
|
|
66
|
+
// mirrors what the detail page exposes today; callers wire the mutations
|
|
67
|
+
// + dialogs and dispatch via `onRowAction`. Argo-only actions (refresh,
|
|
68
|
+
// hard-refresh, terminate) and Flux-only actions (reconcile,
|
|
69
|
+
// sync-with-source) are filtered per-tool inside the table.
|
|
70
|
+
export type GitOpsRowAction =
|
|
71
|
+
| 'sync'
|
|
72
|
+
| 'refresh'
|
|
73
|
+
| 'hard-refresh'
|
|
74
|
+
| 'terminate'
|
|
75
|
+
| 'suspend'
|
|
76
|
+
| 'resume'
|
|
77
|
+
| 'reconcile'
|
|
78
|
+
| 'sync-with-source'
|
|
79
|
+
|
|
57
80
|
// FleetClusterStamp + FleetDestinationStamp — optional fields the hub-side
|
|
58
81
|
// `_cluster` / `_destination` stamping projects into. Keep the types here so
|
|
59
82
|
// callers don't need to import a separate fleet types module; OSS leaves
|
|
@@ -190,6 +213,17 @@ export interface GitOpsTableViewProps {
|
|
|
190
213
|
* button drops it alongside view-local filter state.
|
|
191
214
|
*/
|
|
192
215
|
onClearNamespaces?: () => void
|
|
216
|
+
|
|
217
|
+
// Row-level action dispatcher. When provided, the table renders a
|
|
218
|
+
// right-most three-dot menu per row with Sync / Refresh / Suspend / etc.
|
|
219
|
+
// Caller owns the mutation hooks + any options dialogs (e.g. Argo
|
|
220
|
+
// SyncOptionsDialog). When undefined the actions column is omitted
|
|
221
|
+
// entirely — keeps Hub and other consumers' layout unchanged until they
|
|
222
|
+
// opt in.
|
|
223
|
+
onRowAction?: (row: GitOpsRow, action: GitOpsRowAction) => void
|
|
224
|
+
// In-flight action state, keyed by `row.id`. Drives the per-item
|
|
225
|
+
// spinner so the user can tell which Sync/Refresh is still running.
|
|
226
|
+
pendingRowActions?: Map<string, Set<GitOpsRowAction>>
|
|
193
227
|
}
|
|
194
228
|
|
|
195
229
|
// ----- Main component --------------------------------------------------------
|
|
@@ -213,6 +247,8 @@ export function GitOpsTableView({
|
|
|
213
247
|
emptyStateBody,
|
|
214
248
|
globalNamespaces,
|
|
215
249
|
onClearNamespaces,
|
|
250
|
+
onRowAction,
|
|
251
|
+
pendingRowActions,
|
|
216
252
|
}: GitOpsTableViewProps) {
|
|
217
253
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
218
254
|
const [mode, setMode] = useState<GitOpsMode>('applications')
|
|
@@ -516,7 +552,7 @@ export function GitOpsTableView({
|
|
|
516
552
|
// understand why some destinations show as unmatched.
|
|
517
553
|
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-xs text-amber-700 dark:text-amber-300">
|
|
518
554
|
<span className="font-medium">Cross-cluster mapping limited for {forbiddenSecretsClusters.join(', ')}.</span>{' '}
|
|
519
|
-
Owner needs <code>get secrets</code> in the <code>argocd</code> namespace there; destination
|
|
555
|
+
Owner needs <code className="inline-code">get secrets</code> in the <code className="inline-code">argocd</code> namespace there; destination
|
|
520
556
|
correlation falls back to name-only resolution.
|
|
521
557
|
</div>
|
|
522
558
|
)}
|
|
@@ -611,7 +647,12 @@ export function GitOpsTableView({
|
|
|
611
647
|
</div>
|
|
612
648
|
</div>
|
|
613
649
|
|
|
614
|
-
|
|
650
|
+
{/* pb-20 keeps the last row (and its three-dot menu) scrollable clear
|
|
651
|
+
of the app's fixed bottom-right overlay buttons; without the slack
|
|
652
|
+
the bottom row's action trigger sits under them and can't be clicked
|
|
653
|
+
once the list fills the viewport. Only needed when the actions column
|
|
654
|
+
is present — consumers without onRowAction (e.g. Hub) skip the slack. */}
|
|
655
|
+
<div className={clsx('min-h-0 min-w-0 flex-1 overflow-auto bg-theme-base', onRowAction && 'pb-20')}>
|
|
615
656
|
{mode !== 'applications' ? (
|
|
616
657
|
<div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
|
|
617
658
|
{modeLabel(mode)} view is queued behind the application list.
|
|
@@ -644,7 +685,15 @@ export function GitOpsTableView({
|
|
|
644
685
|
) : viewMode === 'tiles' ? (
|
|
645
686
|
<GitOpsTiles rows={filteredRows} onOpen={onRowClick} hrefFor={rowHrefFor} />
|
|
646
687
|
) : (
|
|
647
|
-
<GitOpsTable
|
|
688
|
+
<GitOpsTable
|
|
689
|
+
rows={filteredRows}
|
|
690
|
+
onOpen={onRowClick}
|
|
691
|
+
hrefFor={rowHrefFor}
|
|
692
|
+
onDestinationClick={onDestinationClick}
|
|
693
|
+
destinationHrefFor={destinationHrefFor}
|
|
694
|
+
onRowAction={onRowAction}
|
|
695
|
+
pendingRowActions={pendingRowActions}
|
|
696
|
+
/>
|
|
648
697
|
)}
|
|
649
698
|
</div>
|
|
650
699
|
</div>
|
|
@@ -1035,24 +1084,34 @@ function GitOpsTable({
|
|
|
1035
1084
|
hrefFor,
|
|
1036
1085
|
onDestinationClick,
|
|
1037
1086
|
destinationHrefFor,
|
|
1087
|
+
onRowAction,
|
|
1088
|
+
pendingRowActions,
|
|
1038
1089
|
}: {
|
|
1039
1090
|
rows: GitOpsRow[]
|
|
1040
1091
|
onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
1041
1092
|
hrefFor?: (row: GitOpsRow) => string
|
|
1042
1093
|
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
1043
1094
|
destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
|
|
1095
|
+
onRowAction?: (row: GitOpsRow, action: GitOpsRowAction) => void
|
|
1096
|
+
pendingRowActions?: Map<string, Set<GitOpsRowAction>>
|
|
1044
1097
|
}) {
|
|
1098
|
+
const showActions = !!onRowAction
|
|
1045
1099
|
return (
|
|
1046
1100
|
<table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
|
|
1047
1101
|
<thead className="sticky top-0 z-10 bg-theme-surface">
|
|
1048
1102
|
<tr className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
|
|
1049
|
-
<TableHead className=
|
|
1103
|
+
<TableHead className={showActions ? 'w-[16%]' : 'w-[22%]'}>Application</TableHead>
|
|
1050
1104
|
<TableHead className="w-[9%]">Project</TableHead>
|
|
1051
1105
|
<TableHead className="w-[9%]">Sync</TableHead>
|
|
1052
1106
|
<TableHead className="w-[9%]">Health</TableHead>
|
|
1053
1107
|
<TableHead className="w-[20%]">Source</TableHead>
|
|
1054
1108
|
<TableHead className="w-[14%]">Destination</TableHead>
|
|
1055
1109
|
<TableHead className="w-[10%]">Last Sync</TableHead>
|
|
1110
|
+
{showActions && (
|
|
1111
|
+
<TableHead className="w-[6%] text-right">
|
|
1112
|
+
<span className="sr-only">Actions</span>
|
|
1113
|
+
</TableHead>
|
|
1114
|
+
)}
|
|
1056
1115
|
</tr>
|
|
1057
1116
|
</thead>
|
|
1058
1117
|
<tbody>
|
|
@@ -1129,6 +1188,14 @@ function GitOpsTable({
|
|
|
1129
1188
|
? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
|
|
1130
1189
|
: formatRelativeAge(row.lastSync || row.createdAt)}
|
|
1131
1190
|
</TableCell>
|
|
1191
|
+
{showActions && onRowAction && (
|
|
1192
|
+
<td
|
|
1193
|
+
className="overflow-visible border-b border-theme-border px-2 py-2 text-right align-middle"
|
|
1194
|
+
onClick={(e) => e.stopPropagation()}
|
|
1195
|
+
>
|
|
1196
|
+
<RowActionMenu items={buildRowActionItems(row, onRowAction, pendingRowActions)} />
|
|
1197
|
+
</td>
|
|
1198
|
+
)}
|
|
1132
1199
|
</tr>
|
|
1133
1200
|
)
|
|
1134
1201
|
})}
|
|
@@ -1137,6 +1204,137 @@ function GitOpsTable({
|
|
|
1137
1204
|
)
|
|
1138
1205
|
}
|
|
1139
1206
|
|
|
1207
|
+
// buildRowActionItems composes the per-row three-dot menu entries based on
|
|
1208
|
+
// the row's tool (Argo vs Flux), current suspend state, terminating state,
|
|
1209
|
+
// and Argo's operationState.phase (used to gate the Terminate entry — only
|
|
1210
|
+
// shown while a sync is mid-flight, mirroring the detail-page condition).
|
|
1211
|
+
function buildRowActionItems(
|
|
1212
|
+
row: GitOpsRow,
|
|
1213
|
+
onAction: (row: GitOpsRow, action: GitOpsRowAction) => void,
|
|
1214
|
+
pending?: Map<string, Set<GitOpsRowAction>>,
|
|
1215
|
+
): RowActionItem[] {
|
|
1216
|
+
const inFlight = pending?.get(row.id)
|
|
1217
|
+
const isPending = (action: GitOpsRowAction) => inFlight?.has(action) ?? false
|
|
1218
|
+
const terminating = row.terminating
|
|
1219
|
+
const suspended = row.suspended
|
|
1220
|
+
// Disabled-reason copy matches what the detail page already shows so
|
|
1221
|
+
// operators see consistent language whichever surface they use.
|
|
1222
|
+
const terminatingReason = 'Resource is terminating; mutating actions are gated until finalizers complete.'
|
|
1223
|
+
const suspendedReason = 'Cannot sync while suspended. Resume first.'
|
|
1224
|
+
const items: RowActionItem[] = []
|
|
1225
|
+
|
|
1226
|
+
if (row.tool === 'argo') {
|
|
1227
|
+
items.push({
|
|
1228
|
+
key: 'sync',
|
|
1229
|
+
label: 'Sync...',
|
|
1230
|
+
icon: ArrowDownUp,
|
|
1231
|
+
onClick: () => onAction(row, 'sync'),
|
|
1232
|
+
disabled: suspended || terminating,
|
|
1233
|
+
disabledReason: terminating ? terminatingReason : suspended ? suspendedReason : undefined,
|
|
1234
|
+
pending: isPending('sync'),
|
|
1235
|
+
})
|
|
1236
|
+
// Refresh / Hard refresh are read-style verbs — they re-read Git and
|
|
1237
|
+
// recompute status without mutating the cluster, so they stay enabled
|
|
1238
|
+
// during termination (matches the detail page + the backend carve-out).
|
|
1239
|
+
items.push({
|
|
1240
|
+
key: 'refresh',
|
|
1241
|
+
label: 'Refresh',
|
|
1242
|
+
icon: RefreshCw,
|
|
1243
|
+
onClick: () => onAction(row, 'refresh'),
|
|
1244
|
+
pending: isPending('refresh'),
|
|
1245
|
+
})
|
|
1246
|
+
items.push({
|
|
1247
|
+
key: 'hard-refresh',
|
|
1248
|
+
label: 'Hard refresh',
|
|
1249
|
+
icon: Zap,
|
|
1250
|
+
onClick: () => onAction(row, 'hard-refresh'),
|
|
1251
|
+
pending: isPending('hard-refresh'),
|
|
1252
|
+
})
|
|
1253
|
+
if (suspended) {
|
|
1254
|
+
items.push({
|
|
1255
|
+
key: 'resume',
|
|
1256
|
+
label: 'Resume',
|
|
1257
|
+
icon: Play,
|
|
1258
|
+
onClick: () => onAction(row, 'resume'),
|
|
1259
|
+
disabled: terminating,
|
|
1260
|
+
disabledReason: terminating ? terminatingReason : undefined,
|
|
1261
|
+
pending: isPending('resume'),
|
|
1262
|
+
divider: true,
|
|
1263
|
+
})
|
|
1264
|
+
} else {
|
|
1265
|
+
items.push({
|
|
1266
|
+
key: 'suspend',
|
|
1267
|
+
label: 'Suspend',
|
|
1268
|
+
icon: Pause,
|
|
1269
|
+
onClick: () => onAction(row, 'suspend'),
|
|
1270
|
+
disabled: terminating,
|
|
1271
|
+
disabledReason: terminating ? terminatingReason : undefined,
|
|
1272
|
+
pending: isPending('suspend'),
|
|
1273
|
+
divider: true,
|
|
1274
|
+
})
|
|
1275
|
+
}
|
|
1276
|
+
// Argo Terminate only makes sense while a sync is Running — gating
|
|
1277
|
+
// here matches the detail-page conditional (gitops detail mounts the
|
|
1278
|
+
// shortcut only when `isRunning`). For non-running rows we just omit
|
|
1279
|
+
// the entry rather than disabling it, to keep the menu tight.
|
|
1280
|
+
if (row.raw?.status?.operationState?.phase === 'Running') {
|
|
1281
|
+
items.push({
|
|
1282
|
+
key: 'terminate',
|
|
1283
|
+
label: 'Terminate sync',
|
|
1284
|
+
icon: Square,
|
|
1285
|
+
onClick: () => onAction(row, 'terminate'),
|
|
1286
|
+
pending: isPending('terminate'),
|
|
1287
|
+
danger: true,
|
|
1288
|
+
})
|
|
1289
|
+
}
|
|
1290
|
+
return items
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Flux (Kustomization / HelmRelease)
|
|
1294
|
+
items.push({
|
|
1295
|
+
key: 'reconcile',
|
|
1296
|
+
label: 'Reconcile',
|
|
1297
|
+
icon: RefreshCw,
|
|
1298
|
+
onClick: () => onAction(row, 'reconcile'),
|
|
1299
|
+
disabled: suspended || terminating,
|
|
1300
|
+
disabledReason: terminating ? terminatingReason : suspended ? suspendedReason : undefined,
|
|
1301
|
+
pending: isPending('reconcile'),
|
|
1302
|
+
})
|
|
1303
|
+
items.push({
|
|
1304
|
+
key: 'sync-with-source',
|
|
1305
|
+
label: 'Reconcile with source',
|
|
1306
|
+
icon: RotateCw,
|
|
1307
|
+
onClick: () => onAction(row, 'sync-with-source'),
|
|
1308
|
+
disabled: suspended || terminating,
|
|
1309
|
+
disabledReason: terminating ? terminatingReason : suspended ? suspendedReason : undefined,
|
|
1310
|
+
pending: isPending('sync-with-source'),
|
|
1311
|
+
})
|
|
1312
|
+
if (suspended) {
|
|
1313
|
+
items.push({
|
|
1314
|
+
key: 'resume',
|
|
1315
|
+
label: 'Resume',
|
|
1316
|
+
icon: Play,
|
|
1317
|
+
onClick: () => onAction(row, 'resume'),
|
|
1318
|
+
disabled: terminating,
|
|
1319
|
+
disabledReason: terminating ? terminatingReason : undefined,
|
|
1320
|
+
pending: isPending('resume'),
|
|
1321
|
+
divider: true,
|
|
1322
|
+
})
|
|
1323
|
+
} else {
|
|
1324
|
+
items.push({
|
|
1325
|
+
key: 'suspend',
|
|
1326
|
+
label: 'Suspend',
|
|
1327
|
+
icon: Pause,
|
|
1328
|
+
onClick: () => onAction(row, 'suspend'),
|
|
1329
|
+
disabled: terminating,
|
|
1330
|
+
disabledReason: terminating ? terminatingReason : undefined,
|
|
1331
|
+
pending: isPending('suspend'),
|
|
1332
|
+
divider: true,
|
|
1333
|
+
})
|
|
1334
|
+
}
|
|
1335
|
+
return items
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1140
1338
|
function GitOpsTiles({
|
|
1141
1339
|
rows,
|
|
1142
1340
|
onOpen,
|
|
@@ -1594,7 +1792,7 @@ export function normalizeArgoApplication(resource: any): GitOpsRow {
|
|
|
1594
1792
|
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
1595
1793
|
sync: status?.sync ?? 'Unknown',
|
|
1596
1794
|
health: status?.health ?? 'Unknown',
|
|
1597
|
-
suspended: status?.suspended ?? false,
|
|
1795
|
+
suspended: (status?.suspended ?? false) || isArgoSuspendedByRadar(resource),
|
|
1598
1796
|
repository: resource.spec?.source?.repoURL ?? '',
|
|
1599
1797
|
targetRevision: resource.spec?.source?.targetRevision ?? '',
|
|
1600
1798
|
path: resource.spec?.source?.path ?? '',
|
|
@@ -1717,4 +1915,3 @@ function newestConditionTime(resource: any): string {
|
|
|
1717
1915
|
.sort()
|
|
1718
1916
|
return times[times.length - 1] ?? ''
|
|
1719
1917
|
}
|
|
1720
|
-
|
|
@@ -818,12 +818,12 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
818
818
|
<div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
819
819
|
Radar reads each resource's drift status from the controller. For a line-by-line diff, {insight.summary.tool === 'fluxcd' ? (
|
|
820
820
|
insight.summary.kind === 'HelmRelease' ? (
|
|
821
|
-
<>run <code className="
|
|
821
|
+
<>run <code className="inline-code text-[10px]">helm diff upgrade {insight.summary.name} <chart></code> (requires the helm-diff plugin).</>
|
|
822
822
|
) : (
|
|
823
|
-
<>run <code className="
|
|
823
|
+
<>run <code className="inline-code text-[10px]">flux diff kustomization {insight.summary.name} --path <local-manifests></code>.</>
|
|
824
824
|
)
|
|
825
825
|
) : (
|
|
826
|
-
<>use the Argo CD UI or run <code className="
|
|
826
|
+
<>use the Argo CD UI or run <code className="inline-code text-[10px]">argocd app diff {insight.summary.name}</code>.</>
|
|
827
827
|
)}
|
|
828
828
|
</div>
|
|
829
829
|
)}
|