@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.
@@ -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
- onRowClick: (row: GitOpsRow) => void
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
- <div className="min-h-0 min-w-0 flex-1 overflow-auto bg-theme-base">
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 rows={filteredRows} onOpen={onRowClick} onDestinationClick={onDestinationClick} />
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="w-[22%]">Application</TableHead>
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
- <tr
1042
- key={row.id}
1043
- onClick={() => onOpen(row)}
1044
- className={clsx(
1045
- 'cursor-pointer border-b border-theme-border bg-theme-base hover:bg-theme-hover',
1046
- row.terminating && 'opacity-70',
1047
- )}
1048
- >
1049
- <TableCell>
1050
- <div className="flex min-w-0 items-center gap-2">
1051
- <span className={`h-8 w-1 shrink-0 rounded-full ${statusStripe(row)}`} />
1052
- {row.terminating && (
1053
- <Tooltip content="Pending deletion finalizers still running">
1054
- <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">
1055
- <Trash2 className="h-3 w-3" />
1056
- Terminating
1057
- </span>
1058
- </Tooltip>
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
- </div>
1072
- </TableCell>
1073
- <TableCell>{row.project || '-'}</TableCell>
1074
- <TableCell>
1075
- {row.terminating
1076
- ? <span className="text-[11px] text-theme-text-tertiary">—</span>
1077
- : <SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />}
1078
- </TableCell>
1079
- <TableCell>
1080
- {row.terminating
1081
- ? <span className="text-[11px] text-theme-text-tertiary">—</span>
1082
- : <HealthStatusBadge health={row.health as any} />}
1083
- </TableCell>
1084
- <TableCell>
1085
- <div className="truncate text-theme-text-primary">{row.repository || row.chart || '-'}</div>
1086
- <div className="truncate text-xs text-theme-text-tertiary">{[row.targetRevision, row.path || row.chart].filter(Boolean).join(' · ') || '-'}</div>
1087
- </TableCell>
1088
- <TableCell>
1089
- <DestinationCell row={row} onDestinationClick={onDestinationClick} />
1090
- <div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
1091
- </TableCell>
1092
- <TableCell>
1093
- {row.terminating
1094
- ? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
1095
- : formatRelativeAge(row.lastSync || row.createdAt)}
1096
- </TableCell>
1097
- </tr>
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
- return (
1134
- <button
1135
- type="button"
1136
- onClick={() => onOpen(row)}
1137
- className={clsx(
1138
- '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',
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={handleClick}
1292
- className={
1293
- '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 ' +
1294
- (highConfidence
1295
- ? 'border border-sky-500/50 bg-sky-500/15 text-sky-700'
1296
- : 'border border-sky-500/25 bg-sky-500/5 text-sky-600')
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
- {highConfidence ? '✓ ' : ''}{short}
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 ?? '',
@@ -22,6 +22,7 @@ export {
22
22
  export type {
23
23
  GitOpsTableViewProps,
24
24
  GitOpsRow,
25
+ GitOpsRowAction,
25
26
  GitOpsMode,
26
27
  GitOpsViewMode,
27
28
  SortKey,