@skyhook-io/k8s-ui 1.7.5 → 1.7.7
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/gitops/GitOpsDetailLayout.tsx +4 -4
- package/src/components/gitops/GitOpsTableView.tsx +363 -89
- package/src/components/gitops/index.ts +1 -0
- package/src/components/resources/ResourcesView.tsx +75 -19
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
- package/src/components/resources/renderers/RBACErrorSection.tsx +6 -9
- package/src/components/resources/renderers/RoleBindingRenderer.test.tsx +76 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +23 -7
- package/src/components/resources/resource-utils-argo.ts +18 -0
- package/src/components/ui/FetchResult.test.tsx +93 -0
- package/src/components/ui/FetchResult.tsx +141 -0
- package/src/components/ui/RowActionMenu.tsx +149 -0
- package/src/components/ui/index.ts +3 -0
- package/src/components/workload/WorkloadView.tsx +13 -18
- package/src/types/fetch-error.test.ts +46 -0
- package/src/types/fetch-error.ts +20 -0
- package/src/types/index.ts +1 -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 +1 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
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
|
|
@@ -141,14 +164,27 @@ export interface GitOpsTableViewProps {
|
|
|
141
164
|
counts: Record<string, number>
|
|
142
165
|
// Caller refresh — typically invalidates its useQuery + refetches.
|
|
143
166
|
onRefresh?: () => void
|
|
144
|
-
// Row click — caller routes to its own detail page.
|
|
145
|
-
|
|
167
|
+
// Row click — caller routes to its own detail page. When the host also
|
|
168
|
+
// passes `rowHrefFor`, the callback receives the MouseEvent so it can
|
|
169
|
+
// `preventDefault()` for SPA-local nav (e.g. react-router) or skip the
|
|
170
|
+
// preventDefault to let the anchor's default full-page navigation run
|
|
171
|
+
// (required for cross-router-boundary links).
|
|
172
|
+
onRowClick: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
173
|
+
/** When provided, the Application-name cell renders as a real `<a href>`
|
|
174
|
+
* and the `<tr>` drops its row-level click handler. Restores ⌘-click /
|
|
175
|
+
* middle-click / "Copy link" / hover URL preview / screen-reader link
|
|
176
|
+
* semantics. `onRowClick` still fires on unmodified clicks (event arg
|
|
177
|
+
* supplied) for analytics or to take over navigation. */
|
|
178
|
+
rowHrefFor?: (row: GitOpsRow) => string
|
|
146
179
|
|
|
147
180
|
// Called when the user clicks the destination cluster chip in the
|
|
148
181
|
// Destination cell. Fleet-only; OSS leaves undefined. Caller routes to
|
|
149
182
|
// the destination cluster's workloads view (filtered by the Argo
|
|
150
183
|
// instance label) — the chip itself stops row-click propagation.
|
|
151
184
|
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
185
|
+
/** Anchor equivalent of `onDestinationClick`. Same rationale as
|
|
186
|
+
* `rowHrefFor` — real `<a href>` for the destination chip. */
|
|
187
|
+
destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
|
|
152
188
|
// Cross-cluster surfaces (Hub-only); OSS leaves these undefined.
|
|
153
189
|
crossClusterCount?: number
|
|
154
190
|
destinationFilter?: DestinationFilter
|
|
@@ -177,6 +213,17 @@ export interface GitOpsTableViewProps {
|
|
|
177
213
|
* button drops it alongside view-local filter state.
|
|
178
214
|
*/
|
|
179
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>>
|
|
180
227
|
}
|
|
181
228
|
|
|
182
229
|
// ----- Main component --------------------------------------------------------
|
|
@@ -188,7 +235,9 @@ export function GitOpsTableView({
|
|
|
188
235
|
counts,
|
|
189
236
|
onRefresh,
|
|
190
237
|
onRowClick,
|
|
238
|
+
rowHrefFor,
|
|
191
239
|
onDestinationClick,
|
|
240
|
+
destinationHrefFor,
|
|
192
241
|
crossClusterCount,
|
|
193
242
|
destinationFilter,
|
|
194
243
|
onDestinationFilterChange,
|
|
@@ -198,6 +247,8 @@ export function GitOpsTableView({
|
|
|
198
247
|
emptyStateBody,
|
|
199
248
|
globalNamespaces,
|
|
200
249
|
onClearNamespaces,
|
|
250
|
+
onRowAction,
|
|
251
|
+
pendingRowActions,
|
|
201
252
|
}: GitOpsTableViewProps) {
|
|
202
253
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
203
254
|
const [mode, setMode] = useState<GitOpsMode>('applications')
|
|
@@ -596,7 +647,12 @@ export function GitOpsTableView({
|
|
|
596
647
|
</div>
|
|
597
648
|
</div>
|
|
598
649
|
|
|
599
|
-
|
|
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')}>
|
|
600
656
|
{mode !== 'applications' ? (
|
|
601
657
|
<div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
|
|
602
658
|
{modeLabel(mode)} view is queued behind the application list.
|
|
@@ -627,9 +683,17 @@ export function GitOpsTableView({
|
|
|
627
683
|
)}
|
|
628
684
|
</div>
|
|
629
685
|
) : viewMode === 'tiles' ? (
|
|
630
|
-
<GitOpsTiles rows={filteredRows} onOpen={onRowClick} />
|
|
686
|
+
<GitOpsTiles rows={filteredRows} onOpen={onRowClick} hrefFor={rowHrefFor} />
|
|
631
687
|
) : (
|
|
632
|
-
<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
|
+
/>
|
|
633
697
|
)}
|
|
634
698
|
</div>
|
|
635
699
|
</div>
|
|
@@ -1017,101 +1081,273 @@ function StatusDistribution({ rows }: { rows: GitOpsRow[] }) {
|
|
|
1017
1081
|
function GitOpsTable({
|
|
1018
1082
|
rows,
|
|
1019
1083
|
onOpen,
|
|
1084
|
+
hrefFor,
|
|
1020
1085
|
onDestinationClick,
|
|
1086
|
+
destinationHrefFor,
|
|
1087
|
+
onRowAction,
|
|
1088
|
+
pendingRowActions,
|
|
1021
1089
|
}: {
|
|
1022
1090
|
rows: GitOpsRow[]
|
|
1023
|
-
onOpen: (row: GitOpsRow) => void
|
|
1091
|
+
onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
1092
|
+
hrefFor?: (row: GitOpsRow) => string
|
|
1024
1093
|
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
1094
|
+
destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
|
|
1095
|
+
onRowAction?: (row: GitOpsRow, action: GitOpsRowAction) => void
|
|
1096
|
+
pendingRowActions?: Map<string, Set<GitOpsRowAction>>
|
|
1025
1097
|
}) {
|
|
1098
|
+
const showActions = !!onRowAction
|
|
1026
1099
|
return (
|
|
1027
1100
|
<table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
|
|
1028
1101
|
<thead className="sticky top-0 z-10 bg-theme-surface">
|
|
1029
1102
|
<tr className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
|
|
1030
|
-
<TableHead className=
|
|
1103
|
+
<TableHead className={showActions ? 'w-[16%]' : 'w-[22%]'}>Application</TableHead>
|
|
1031
1104
|
<TableHead className="w-[9%]">Project</TableHead>
|
|
1032
1105
|
<TableHead className="w-[9%]">Sync</TableHead>
|
|
1033
1106
|
<TableHead className="w-[9%]">Health</TableHead>
|
|
1034
1107
|
<TableHead className="w-[20%]">Source</TableHead>
|
|
1035
1108
|
<TableHead className="w-[14%]">Destination</TableHead>
|
|
1036
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
|
+
)}
|
|
1037
1115
|
</tr>
|
|
1038
1116
|
</thead>
|
|
1039
1117
|
<tbody>
|
|
1040
|
-
{rows.map((row) =>
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
<div className="min-w-0">
|
|
1061
|
-
<div className="truncate font-medium text-theme-text-primary">{row.name}</div>
|
|
1062
|
-
<div className="truncate text-xs text-theme-text-tertiary">
|
|
1063
|
-
{row.tool === 'argo' ? 'ArgoCD' : 'FluxCD'} {row.kind}
|
|
1064
|
-
{row._cluster && (
|
|
1065
|
-
<span title={row._cluster.name !== shortClusterName(row._cluster.name) ? row._cluster.name : undefined}>
|
|
1066
|
-
{' · '}{shortClusterName(row._cluster.name)}
|
|
1118
|
+
{rows.map((row) => {
|
|
1119
|
+
const href = hrefFor?.(row)
|
|
1120
|
+
return (
|
|
1121
|
+
<tr
|
|
1122
|
+
key={row.id}
|
|
1123
|
+
onClick={href ? undefined : () => onOpen(row)}
|
|
1124
|
+
className={clsx(
|
|
1125
|
+
'border-b border-theme-border bg-theme-base hover:bg-theme-hover',
|
|
1126
|
+
!href && 'cursor-pointer',
|
|
1127
|
+
row.terminating && 'opacity-70',
|
|
1128
|
+
)}
|
|
1129
|
+
>
|
|
1130
|
+
<TableCell>
|
|
1131
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
1132
|
+
<span className={`h-8 w-1 shrink-0 rounded-full ${statusStripe(row)}`} />
|
|
1133
|
+
{row.terminating && (
|
|
1134
|
+
<Tooltip content="Pending deletion — finalizers still running">
|
|
1135
|
+
<span className="inline-flex shrink-0 items-center gap-1 rounded border border-orange-500/40 bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-orange-400">
|
|
1136
|
+
<Trash2 className="h-3 w-3" />
|
|
1137
|
+
Terminating
|
|
1067
1138
|
</span>
|
|
1139
|
+
</Tooltip>
|
|
1140
|
+
)}
|
|
1141
|
+
<div className="min-w-0">
|
|
1142
|
+
{href ? (
|
|
1143
|
+
<a
|
|
1144
|
+
href={href}
|
|
1145
|
+
onClick={(e) => {
|
|
1146
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
|
|
1147
|
+
onOpen(row, e)
|
|
1148
|
+
}}
|
|
1149
|
+
className="block truncate font-medium text-theme-text-primary hover:underline focus-visible:underline focus-visible:outline-none rounded-sm"
|
|
1150
|
+
>
|
|
1151
|
+
{row.name}
|
|
1152
|
+
</a>
|
|
1153
|
+
) : (
|
|
1154
|
+
<div className="truncate font-medium text-theme-text-primary">{row.name}</div>
|
|
1068
1155
|
)}
|
|
1156
|
+
<div className="truncate text-xs text-theme-text-tertiary">
|
|
1157
|
+
{row.tool === 'argo' ? 'ArgoCD' : 'FluxCD'} {row.kind}
|
|
1158
|
+
{row._cluster && (
|
|
1159
|
+
<span title={row._cluster.name !== shortClusterName(row._cluster.name) ? row._cluster.name : undefined}>
|
|
1160
|
+
{' · '}{shortClusterName(row._cluster.name)}
|
|
1161
|
+
</span>
|
|
1162
|
+
)}
|
|
1163
|
+
</div>
|
|
1069
1164
|
</div>
|
|
1070
1165
|
</div>
|
|
1071
|
-
</
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1166
|
+
</TableCell>
|
|
1167
|
+
<TableCell>{row.project || '-'}</TableCell>
|
|
1168
|
+
<TableCell>
|
|
1169
|
+
{row.terminating
|
|
1170
|
+
? <span className="text-[11px] text-theme-text-tertiary">—</span>
|
|
1171
|
+
: <SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />}
|
|
1172
|
+
</TableCell>
|
|
1173
|
+
<TableCell>
|
|
1174
|
+
{row.terminating
|
|
1175
|
+
? <span className="text-[11px] text-theme-text-tertiary">—</span>
|
|
1176
|
+
: <HealthStatusBadge health={row.health as any} />}
|
|
1177
|
+
</TableCell>
|
|
1178
|
+
<TableCell>
|
|
1179
|
+
<div className="truncate text-theme-text-primary">{row.repository || row.chart || '-'}</div>
|
|
1180
|
+
<div className="truncate text-xs text-theme-text-tertiary">{[row.targetRevision, row.path || row.chart].filter(Boolean).join(' · ') || '-'}</div>
|
|
1181
|
+
</TableCell>
|
|
1182
|
+
<TableCell>
|
|
1183
|
+
<DestinationCell row={row} onDestinationClick={onDestinationClick} destinationHrefFor={destinationHrefFor} />
|
|
1184
|
+
<div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
|
|
1185
|
+
</TableCell>
|
|
1186
|
+
<TableCell>
|
|
1187
|
+
{row.terminating
|
|
1188
|
+
? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
|
|
1189
|
+
: formatRelativeAge(row.lastSync || row.createdAt)}
|
|
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
|
+
)}
|
|
1199
|
+
</tr>
|
|
1200
|
+
)
|
|
1201
|
+
})}
|
|
1099
1202
|
</tbody>
|
|
1100
1203
|
</table>
|
|
1101
1204
|
)
|
|
1102
1205
|
}
|
|
1103
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
|
+
|
|
1104
1338
|
function GitOpsTiles({
|
|
1105
1339
|
rows,
|
|
1106
1340
|
onOpen,
|
|
1341
|
+
hrefFor,
|
|
1107
1342
|
}: {
|
|
1108
1343
|
rows: GitOpsRow[]
|
|
1109
|
-
onOpen: (row: GitOpsRow) => void
|
|
1344
|
+
onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
1345
|
+
hrefFor?: (row: GitOpsRow) => string
|
|
1110
1346
|
}) {
|
|
1111
1347
|
return (
|
|
1112
1348
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3 p-4">
|
|
1113
1349
|
{rows.map((row) => (
|
|
1114
|
-
<GitOpsTile key={row.id} row={row} onOpen={onOpen} />
|
|
1350
|
+
<GitOpsTile key={row.id} row={row} onOpen={onOpen} href={hrefFor?.(row)} />
|
|
1115
1351
|
))}
|
|
1116
1352
|
</div>
|
|
1117
1353
|
)
|
|
@@ -1120,9 +1356,11 @@ function GitOpsTiles({
|
|
|
1120
1356
|
function GitOpsTile({
|
|
1121
1357
|
row,
|
|
1122
1358
|
onOpen,
|
|
1359
|
+
href,
|
|
1123
1360
|
}: {
|
|
1124
1361
|
row: GitOpsRow
|
|
1125
|
-
onOpen: (row: GitOpsRow) => void
|
|
1362
|
+
onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
|
|
1363
|
+
href?: string
|
|
1126
1364
|
}) {
|
|
1127
1365
|
const source = compactRepoSource(row.repository || row.chart, row.path || row.chart)
|
|
1128
1366
|
const revision = row.targetRevision || ''
|
|
@@ -1130,15 +1368,12 @@ function GitOpsTile({
|
|
|
1130
1368
|
const recencyClass = recencyTone(lastSyncRaw)
|
|
1131
1369
|
const dest = row.destination ? compactClusterURL(row.destination) : ''
|
|
1132
1370
|
const ns = row.destinationNamespace || row.namespace
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
row.terminating && 'opacity-80',
|
|
1140
|
-
)}
|
|
1141
|
-
>
|
|
1371
|
+
const tileClass = clsx(
|
|
1372
|
+
'group relative flex min-w-0 flex-col overflow-hidden rounded-md border border-theme-border bg-theme-surface text-left shadow-theme-sm transition-all hover:border-theme-text-tertiary/40 hover:shadow-theme-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-theme-text-primary/20',
|
|
1373
|
+
row.terminating && 'opacity-80',
|
|
1374
|
+
)
|
|
1375
|
+
const body = (
|
|
1376
|
+
<>
|
|
1142
1377
|
<div className={clsx('h-1 w-full', statusStripe(row))} />
|
|
1143
1378
|
<div className="flex flex-1 flex-col gap-3 px-4 pb-4 pt-3">
|
|
1144
1379
|
<div className="line-clamp-2 break-all text-[15px] font-semibold leading-tight text-theme-text-primary">
|
|
@@ -1184,6 +1419,25 @@ function GitOpsTile({
|
|
|
1184
1419
|
</div>
|
|
1185
1420
|
)}
|
|
1186
1421
|
</div>
|
|
1422
|
+
</>
|
|
1423
|
+
)
|
|
1424
|
+
if (href) {
|
|
1425
|
+
return (
|
|
1426
|
+
<a
|
|
1427
|
+
href={href}
|
|
1428
|
+
onClick={(e) => {
|
|
1429
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
|
|
1430
|
+
onOpen(row, e)
|
|
1431
|
+
}}
|
|
1432
|
+
className={tileClass}
|
|
1433
|
+
>
|
|
1434
|
+
{body}
|
|
1435
|
+
</a>
|
|
1436
|
+
)
|
|
1437
|
+
}
|
|
1438
|
+
return (
|
|
1439
|
+
<button type="button" onClick={() => onOpen(row)} className={tileClass}>
|
|
1440
|
+
{body}
|
|
1187
1441
|
</button>
|
|
1188
1442
|
)
|
|
1189
1443
|
}
|
|
@@ -1260,9 +1514,11 @@ function TableHead({ children, className = '' }: { children: ReactNode; classNam
|
|
|
1260
1514
|
function DestinationCell({
|
|
1261
1515
|
row,
|
|
1262
1516
|
onDestinationClick,
|
|
1517
|
+
destinationHrefFor,
|
|
1263
1518
|
}: {
|
|
1264
1519
|
row: GitOpsRow
|
|
1265
1520
|
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
1521
|
+
destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
|
|
1266
1522
|
}) {
|
|
1267
1523
|
const dest = row._destination
|
|
1268
1524
|
// Non-fleet (OSS) path — show the raw destination string.
|
|
@@ -1273,10 +1529,6 @@ function DestinationCell({
|
|
|
1273
1529
|
return <span className="block truncate text-theme-text-tertiary">same cluster</span>
|
|
1274
1530
|
}
|
|
1275
1531
|
if ((dest.match === 'exact' || dest.match === 'inferred') && dest.cluster_id && dest.cluster_name) {
|
|
1276
|
-
const handleClick = (e: React.MouseEvent) => {
|
|
1277
|
-
e.stopPropagation()
|
|
1278
|
-
onDestinationClick?.(row, dest)
|
|
1279
|
-
}
|
|
1280
1532
|
const short = shortClusterName(dest.cluster_name)
|
|
1281
1533
|
// High-confidence (URL match): solid sky chip with a small ✓ marker.
|
|
1282
1534
|
// Medium-confidence (name match): same chip styling but no marker, and
|
|
@@ -1285,19 +1537,41 @@ function DestinationCell({
|
|
|
1285
1537
|
// the human-readable reason from the hub.
|
|
1286
1538
|
const highConfidence = dest.confidence === 'high'
|
|
1287
1539
|
const tooltipReason = dest.reason ? ` (${dest.reason})` : ''
|
|
1540
|
+
const chipClass =
|
|
1541
|
+
'block max-w-full truncate rounded px-1.5 py-0.5 text-xs font-medium hover:bg-sky-500/20 dark:text-sky-300 ' +
|
|
1542
|
+
(highConfidence
|
|
1543
|
+
? 'border border-sky-500/50 bg-sky-500/15 text-sky-700'
|
|
1544
|
+
: 'border border-sky-500/25 bg-sky-500/5 text-sky-600')
|
|
1545
|
+
const title = `Open workloads in ${dest.cluster_name}${tooltipReason}`
|
|
1546
|
+
const chipBody = `${highConfidence ? '✓ ' : ''}${short}`
|
|
1547
|
+
const destHref = destinationHrefFor?.(row, dest)
|
|
1548
|
+
if (destHref) {
|
|
1549
|
+
return (
|
|
1550
|
+
<a
|
|
1551
|
+
href={destHref}
|
|
1552
|
+
// The chip sits inside the row's `<td>`; when a host wires
|
|
1553
|
+
// `destinationHrefFor` without `rowHrefFor`, the `<tr>` retains
|
|
1554
|
+
// its own onClick. Stop the bubble so a click on the chip
|
|
1555
|
+
// doesn't also trigger row navigation.
|
|
1556
|
+
onClick={(e) => e.stopPropagation()}
|
|
1557
|
+
className={chipClass + ' focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/40'}
|
|
1558
|
+
title={title}
|
|
1559
|
+
>
|
|
1560
|
+
{chipBody}
|
|
1561
|
+
</a>
|
|
1562
|
+
)
|
|
1563
|
+
}
|
|
1288
1564
|
return (
|
|
1289
1565
|
<button
|
|
1290
1566
|
type="button"
|
|
1291
|
-
onClick={
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
}
|
|
1298
|
-
title={`Open workloads in ${dest.cluster_name}${tooltipReason}`}
|
|
1567
|
+
onClick={(e) => {
|
|
1568
|
+
e.stopPropagation()
|
|
1569
|
+
onDestinationClick?.(row, dest)
|
|
1570
|
+
}}
|
|
1571
|
+
className={chipClass}
|
|
1572
|
+
title={title}
|
|
1299
1573
|
>
|
|
1300
|
-
{
|
|
1574
|
+
{chipBody}
|
|
1301
1575
|
</button>
|
|
1302
1576
|
)
|
|
1303
1577
|
}
|
|
@@ -1518,7 +1792,7 @@ export function normalizeArgoApplication(resource: any): GitOpsRow {
|
|
|
1518
1792
|
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
1519
1793
|
sync: status?.sync ?? 'Unknown',
|
|
1520
1794
|
health: status?.health ?? 'Unknown',
|
|
1521
|
-
suspended: status?.suspended ?? false,
|
|
1795
|
+
suspended: (status?.suspended ?? false) || isArgoSuspendedByRadar(resource),
|
|
1522
1796
|
repository: resource.spec?.source?.repoURL ?? '',
|
|
1523
1797
|
targetRevision: resource.spec?.source?.targetRevision ?? '',
|
|
1524
1798
|
path: resource.spec?.source?.path ?? '',
|