@skyhook-io/k8s-ui 1.7.6 → 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 +202 -4
- package/src/components/gitops/index.ts +1 -0
- package/src/components/resources/ResourcesView.tsx +25 -6
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
- package/src/components/resources/resource-utils-argo.ts +18 -0
- package/src/components/ui/RowActionMenu.tsx +149 -0
- package/src/components/ui/index.ts +2 -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
package/package.json
CHANGED
|
@@ -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')
|
|
@@ -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 ?? '',
|
|
@@ -229,6 +229,13 @@ const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
|
|
|
229
229
|
'w-48': 192, 'w-56': 224, 'w-64': 256,
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
const COMPARE_COLUMN_WIDTH = 36
|
|
233
|
+
const COMPARE_COLUMN_STYLE: React.CSSProperties = {
|
|
234
|
+
width: COMPARE_COLUMN_WIDTH,
|
|
235
|
+
minWidth: COMPARE_COLUMN_WIDTH,
|
|
236
|
+
maxWidth: COMPARE_COLUMN_WIDTH,
|
|
237
|
+
}
|
|
238
|
+
|
|
232
239
|
function getColumnMinWidth(col: Column): number {
|
|
233
240
|
if (col.minWidth) return col.minWidth
|
|
234
241
|
if (!col.width) return 200 // Name column (no width class) gets wider minimum
|
|
@@ -3367,6 +3374,18 @@ export function ResourcesView({
|
|
|
3367
3374
|
return allColumns.filter(c => visibleColumns.has(c.key))
|
|
3368
3375
|
}, [allColumns, visibleColumns])
|
|
3369
3376
|
|
|
3377
|
+
// Fixed-width columns can consume the table's flexible space and collapse
|
|
3378
|
+
// the required name column. Keep a real table minimum and let the container
|
|
3379
|
+
// scroll horizontally when the viewport is too narrow.
|
|
3380
|
+
const tableMinWidth = useMemo(() => {
|
|
3381
|
+
const compareColumnWidth = compareMode ? COMPARE_COLUMN_WIDTH : 0
|
|
3382
|
+
const baseMinWidth = columns.reduce((sum, col) => sum + (columnWidths[col.key] || getColumnMinWidth(col)), compareColumnWidth)
|
|
3383
|
+
const flexibleNameColumn = columns.find(col => col.key === 'name' && !columnWidths[col.key])
|
|
3384
|
+
|
|
3385
|
+
if (!hasResizedColumns || !flexibleNameColumn) return baseMinWidth
|
|
3386
|
+
return baseMinWidth + getColumnMinWidth(flexibleNameColumn)
|
|
3387
|
+
}, [columns, columnWidths, compareMode, hasResizedColumns])
|
|
3388
|
+
|
|
3370
3389
|
// Stable virtuoso components — memoized to avoid remounting the table on every render
|
|
3371
3390
|
const virtuosoComponents = useMemo(() => ({
|
|
3372
3391
|
Table: React.forwardRef<HTMLTableElement, React.TableHTMLAttributes<HTMLTableElement>>(function VirtuosoTable(props, ref) {
|
|
@@ -3375,7 +3394,7 @@ export function ResourcesView({
|
|
|
3375
3394
|
{...props}
|
|
3376
3395
|
ref={ref}
|
|
3377
3396
|
className="w-full"
|
|
3378
|
-
style={{ ...props.style, tableLayout: 'fixed' }}
|
|
3397
|
+
style={{ ...props.style, tableLayout: 'fixed', minWidth: tableMinWidth }}
|
|
3379
3398
|
>
|
|
3380
3399
|
<colgroup>
|
|
3381
3400
|
{/*
|
|
@@ -3385,7 +3404,7 @@ export function ResourcesView({
|
|
|
3385
3404
|
the missing entry by stealing width from a sized neighbour
|
|
3386
3405
|
— typically blowing this narrow column out to ~200px.
|
|
3387
3406
|
*/}
|
|
3388
|
-
{compareMode && <col style={{ width:
|
|
3407
|
+
{compareMode && <col style={{ width: COMPARE_COLUMN_WIDTH }} />}
|
|
3389
3408
|
{columns.map(col => (
|
|
3390
3409
|
<col
|
|
3391
3410
|
key={col.key}
|
|
@@ -3403,7 +3422,7 @@ export function ResourcesView({
|
|
|
3403
3422
|
)
|
|
3404
3423
|
}),
|
|
3405
3424
|
TableRow: VirtuosoTableRow,
|
|
3406
|
-
}), [columns, columnWidths, hasResizedColumns, compareMode])
|
|
3425
|
+
}), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth])
|
|
3407
3426
|
|
|
3408
3427
|
// Calculate filter options with counts based on current resources (before filtering)
|
|
3409
3428
|
const filterOptions = useMemo(() => {
|
|
@@ -3949,7 +3968,7 @@ export function ResourcesView({
|
|
|
3949
3968
|
|
|
3950
3969
|
{/* Table */}
|
|
3951
3970
|
<div
|
|
3952
|
-
className="flex-1 overflow-
|
|
3971
|
+
className="flex-1 overflow-auto relative"
|
|
3953
3972
|
ref={tableContainerRef}
|
|
3954
3973
|
onClick={(e) => {
|
|
3955
3974
|
if (e.target === e.currentTarget && selectedResource) {
|
|
@@ -4062,7 +4081,7 @@ export function ResourcesView({
|
|
|
4062
4081
|
// Inline px width — under `table-layout:fixed`,
|
|
4063
4082
|
// `w-9` is a hint the browser absorbs into leftover
|
|
4064
4083
|
// row width on an icon-only column.
|
|
4065
|
-
style={
|
|
4084
|
+
style={COMPARE_COLUMN_STYLE}
|
|
4066
4085
|
className="px-2 py-3 text-xs font-medium uppercase tracking-wide bg-theme-base border-b border-r-subtle border-theme-border text-center text-skyhook-400"
|
|
4067
4086
|
title="Compare mode"
|
|
4068
4087
|
>
|
|
@@ -4344,7 +4363,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
|
|
|
4344
4363
|
<td
|
|
4345
4364
|
onClick={onClick}
|
|
4346
4365
|
onMouseEnter={onMouseEnter}
|
|
4347
|
-
style={
|
|
4366
|
+
style={COMPARE_COLUMN_STYLE}
|
|
4348
4367
|
className={clsx('px-2 py-3 border-b-subtle cursor-pointer text-center align-middle transition-colors', rowHighlight)}
|
|
4349
4368
|
>
|
|
4350
4369
|
{pickedSide ? (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks } from 'lucide-react'
|
|
1
|
+
import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks, ExternalLink } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
|
|
4
4
|
import { formatAge } from '../resource-utils'
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
type ArgoResource,
|
|
11
11
|
} from '../../../types/gitops'
|
|
12
12
|
import { BADGE_INACTIVE } from '../../../utils/badge-colors'
|
|
13
|
+
import { buildRepoBrowseUrl, buildPathBrowseUrl } from '../../../utils/git-provider-urls'
|
|
14
|
+
|
|
15
|
+
const REPO_LINK_CLASS = 'text-blue-400 hover:text-blue-300 hover:underline break-all'
|
|
13
16
|
|
|
14
17
|
interface ArgoApplicationRendererProps {
|
|
15
18
|
data: any
|
|
@@ -19,10 +22,51 @@ interface ArgoApplicationRendererProps {
|
|
|
19
22
|
|
|
20
23
|
function SourceProperties({ source }: { source: any }) {
|
|
21
24
|
if (!source) return null
|
|
25
|
+
// Helm chart sources point repoURL at a chart registry, not a browseable git repo.
|
|
26
|
+
const isHelmSource = !!source.chart
|
|
27
|
+
const repoHref = isHelmSource ? null : buildRepoBrowseUrl(source.repoURL)
|
|
28
|
+
const pathHref = isHelmSource ? null : buildPathBrowseUrl(source.repoURL, source.path, source.targetRevision)
|
|
22
29
|
return (
|
|
23
30
|
<>
|
|
24
|
-
<Property
|
|
25
|
-
|
|
31
|
+
<Property
|
|
32
|
+
label="Repository"
|
|
33
|
+
value={
|
|
34
|
+
repoHref ? (
|
|
35
|
+
<a
|
|
36
|
+
href={repoHref}
|
|
37
|
+
target="_blank"
|
|
38
|
+
rel="noopener noreferrer"
|
|
39
|
+
title={source.repoURL}
|
|
40
|
+
className={`${REPO_LINK_CLASS} inline-flex items-center gap-1`}
|
|
41
|
+
>
|
|
42
|
+
{source.repoURL}
|
|
43
|
+
<ExternalLink className="w-3 h-3 shrink-0" />
|
|
44
|
+
</a>
|
|
45
|
+
) : (
|
|
46
|
+
source.repoURL
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
/>
|
|
50
|
+
{source.path && (
|
|
51
|
+
<Property
|
|
52
|
+
label="Path"
|
|
53
|
+
value={
|
|
54
|
+
pathHref ? (
|
|
55
|
+
<a
|
|
56
|
+
href={pathHref}
|
|
57
|
+
target="_blank"
|
|
58
|
+
rel="noopener noreferrer"
|
|
59
|
+
title={source.path}
|
|
60
|
+
className={REPO_LINK_CLASS}
|
|
61
|
+
>
|
|
62
|
+
{source.path}
|
|
63
|
+
</a>
|
|
64
|
+
) : (
|
|
65
|
+
source.path
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
26
70
|
{source.targetRevision && (
|
|
27
71
|
<Property
|
|
28
72
|
label="Target Revision"
|
|
@@ -57,6 +57,24 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
|
|
|
57
57
|
return { text: health || sync || 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Radar suspends an Argo Application by clearing spec.syncPolicy.automated and
|
|
61
|
+
// recording the prior prune/selfHeal flags in annotations so Resume can restore
|
|
62
|
+
// them. The *presence* of any of these annotations marks the app as suspended —
|
|
63
|
+
// independent of health.status, which stays whatever the app's resources report
|
|
64
|
+
// (often Missing/OutOfSync, never literally "Suspended"). Both the current
|
|
65
|
+
// radarhq.io keys and the legacy skyhook.io keys (still on apps suspended by
|
|
66
|
+
// older builds) count. Shared so the fleet table and the detail page can't
|
|
67
|
+
// disagree on whether an app is suspended.
|
|
68
|
+
export function isArgoSuspendedByRadar(app: any): boolean {
|
|
69
|
+
const a = app?.metadata?.annotations
|
|
70
|
+
return Boolean(
|
|
71
|
+
a?.['radarhq.io/suspended-prune'] ||
|
|
72
|
+
a?.['radarhq.io/suspended-selfheal'] ||
|
|
73
|
+
a?.['skyhook.io/suspended-prune'] ||
|
|
74
|
+
a?.['skyhook.io/suspended-selfheal'],
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
// ============================================================================
|
|
61
79
|
// ARGOCD TABLE CELL UTILITIES
|
|
62
80
|
// ============================================================================
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from 'react'
|
|
2
|
+
import { Loader2, MoreVertical } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { Tooltip } from './Tooltip'
|
|
5
|
+
|
|
6
|
+
export interface RowActionItem {
|
|
7
|
+
key: string
|
|
8
|
+
label: string
|
|
9
|
+
icon: ComponentType<{ className?: string }>
|
|
10
|
+
onClick: () => void
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
disabledReason?: string
|
|
13
|
+
pending?: boolean
|
|
14
|
+
danger?: boolean
|
|
15
|
+
/** Render a horizontal divider above this item. */
|
|
16
|
+
divider?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RowActionMenuProps {
|
|
20
|
+
items: RowActionItem[]
|
|
21
|
+
ariaLabel?: string
|
|
22
|
+
/** Compact button variant (default: true) — sized for table-row anchoring. */
|
|
23
|
+
compact?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true }: RowActionMenuProps) {
|
|
27
|
+
const [open, setOpen] = useState(false)
|
|
28
|
+
// Flip the menu above the trigger when it would otherwise spill past the
|
|
29
|
+
// viewport bottom. The GitOps table's bottom rows sit at the end of a scroll
|
|
30
|
+
// container with the app's fixed overlay buttons below them, so a
|
|
31
|
+
// downward-opening menu there clips its lowest items with no way to scroll
|
|
32
|
+
// them into view. Measured after open (useLayoutEffect, pre-paint, no flicker).
|
|
33
|
+
const [openUp, setOpenUp] = useState(false)
|
|
34
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
35
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
36
|
+
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
if (!open) {
|
|
39
|
+
setOpenUp(false)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
const trigger = ref.current?.getBoundingClientRect()
|
|
43
|
+
const menuH = menuRef.current?.offsetHeight ?? 0
|
|
44
|
+
if (!trigger) return
|
|
45
|
+
const spaceBelow = window.innerHeight - trigger.bottom
|
|
46
|
+
// Flip up only when there's not enough room below AND enough room above,
|
|
47
|
+
// so a tall menu near the top doesn't get clipped at the other end.
|
|
48
|
+
setOpenUp(menuH + 8 > spaceBelow && trigger.top > menuH + 8)
|
|
49
|
+
}, [open])
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!open) return
|
|
53
|
+
const onDown = (e: MouseEvent) => {
|
|
54
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
55
|
+
}
|
|
56
|
+
const onKey = (e: KeyboardEvent) => {
|
|
57
|
+
if (e.key === 'Escape') setOpen(false)
|
|
58
|
+
}
|
|
59
|
+
document.addEventListener('mousedown', onDown)
|
|
60
|
+
document.addEventListener('keydown', onKey)
|
|
61
|
+
return () => {
|
|
62
|
+
document.removeEventListener('mousedown', onDown)
|
|
63
|
+
document.removeEventListener('keydown', onKey)
|
|
64
|
+
}
|
|
65
|
+
}, [open])
|
|
66
|
+
|
|
67
|
+
const triggerSize = compact ? 'p-1' : 'p-1.5'
|
|
68
|
+
const iconSize = compact ? 'h-4 w-4' : 'h-5 w-5'
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div ref={ref} className="relative inline-block">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
aria-label={ariaLabel}
|
|
75
|
+
aria-haspopup="menu"
|
|
76
|
+
aria-expanded={open}
|
|
77
|
+
onClick={(e) => {
|
|
78
|
+
e.stopPropagation()
|
|
79
|
+
setOpen((v) => !v)
|
|
80
|
+
}}
|
|
81
|
+
className={clsx(
|
|
82
|
+
'rounded text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary',
|
|
83
|
+
triggerSize,
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<MoreVertical className={iconSize} />
|
|
87
|
+
</button>
|
|
88
|
+
{open && (
|
|
89
|
+
<div
|
|
90
|
+
ref={menuRef}
|
|
91
|
+
role="menu"
|
|
92
|
+
className={clsx(
|
|
93
|
+
'absolute right-0 z-50 min-w-[180px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
|
|
94
|
+
openUp ? 'bottom-full mb-1' : 'top-full mt-1',
|
|
95
|
+
)}
|
|
96
|
+
onClick={(e) => e.stopPropagation()}
|
|
97
|
+
>
|
|
98
|
+
{items.map((item) => {
|
|
99
|
+
const Icon = item.icon
|
|
100
|
+
const content = (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
role="menuitem"
|
|
104
|
+
disabled={item.disabled || item.pending}
|
|
105
|
+
onClick={(e) => {
|
|
106
|
+
e.stopPropagation()
|
|
107
|
+
if (item.disabled || item.pending) return
|
|
108
|
+
item.onClick()
|
|
109
|
+
setOpen(false)
|
|
110
|
+
}}
|
|
111
|
+
className={clsx(
|
|
112
|
+
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
|
|
113
|
+
item.disabled || item.pending
|
|
114
|
+
? 'cursor-not-allowed text-theme-text-tertiary'
|
|
115
|
+
: item.danger
|
|
116
|
+
? 'text-red-500 hover:bg-theme-hover hover:text-red-400'
|
|
117
|
+
: 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{item.pending ? (
|
|
121
|
+
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
|
122
|
+
) : (
|
|
123
|
+
<Icon className="h-3.5 w-3.5 shrink-0" />
|
|
124
|
+
)}
|
|
125
|
+
<span className="truncate">{item.label}</span>
|
|
126
|
+
</button>
|
|
127
|
+
)
|
|
128
|
+
return (
|
|
129
|
+
<Fragment key={item.key}>
|
|
130
|
+
{item.divider && <div className="my-1 h-px bg-theme-border" />}
|
|
131
|
+
{item.disabled && item.disabledReason ? (
|
|
132
|
+
// wrapperClassName=w-full so the disabled item fills the menu
|
|
133
|
+
// like enabled items — the Tooltip wrapper is inline-flex and
|
|
134
|
+
// would otherwise shrink-wrap, and the menu inherits text-right
|
|
135
|
+
// from the table's actions cell, shoving the item to the edge.
|
|
136
|
+
<Tooltip content={item.disabledReason} position="left" wrapperClassName="w-full">
|
|
137
|
+
{content}
|
|
138
|
+
</Tooltip>
|
|
139
|
+
) : (
|
|
140
|
+
content
|
|
141
|
+
)}
|
|
142
|
+
</Fragment>
|
|
143
|
+
)
|
|
144
|
+
})}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -20,3 +20,5 @@ export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
|
|
|
20
20
|
export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
|
|
21
21
|
export { CodeViewer } from './CodeViewer'
|
|
22
22
|
export { YamlEditor, YamlDiffEditor } from './YamlEditor'
|
|
23
|
+
export { RowActionMenu } from './RowActionMenu'
|
|
24
|
+
export type { RowActionItem } from './RowActionMenu'
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildRepoBrowseUrl, buildPathBrowseUrl } from './git-provider-urls'
|
|
3
|
+
|
|
4
|
+
describe('buildRepoBrowseUrl', () => {
|
|
5
|
+
// Pass-through semantics: link the user's URL exactly as configured, no rewriting.
|
|
6
|
+
it('returns the input verbatim for https URL', () => {
|
|
7
|
+
expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment')).toBe(
|
|
8
|
+
'https://github.com/KoalaOps/deployment'
|
|
9
|
+
)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('preserves trailing .git suffix (no manipulation)', () => {
|
|
13
|
+
expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment.git')).toBe(
|
|
14
|
+
'https://github.com/KoalaOps/deployment.git'
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('preserves trailing slash (no manipulation)', () => {
|
|
19
|
+
expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment/')).toBe(
|
|
20
|
+
'https://github.com/KoalaOps/deployment/'
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('trims surrounding whitespace', () => {
|
|
25
|
+
expect(buildRepoBrowseUrl(' https://github.com/o/r ')).toBe(
|
|
26
|
+
'https://github.com/o/r'
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps unknown hosts (still a valid http(s) link)', () => {
|
|
31
|
+
expect(buildRepoBrowseUrl('https://git.internal.example.com/team/proj')).toBe(
|
|
32
|
+
'https://git.internal.example.com/team/proj'
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('preserves non-default port in self-hosted URLs', () => {
|
|
37
|
+
expect(buildRepoBrowseUrl('https://gitea.internal:3000/team/repo')).toBe(
|
|
38
|
+
'https://gitea.internal:3000/team/repo'
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('preserves http:// scheme (does not silently upgrade to https)', () => {
|
|
43
|
+
expect(buildRepoBrowseUrl('http://corp.example.com/team/repo')).toBe(
|
|
44
|
+
'http://corp.example.com/team/repo'
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns null for SCP-form (git@host:owner/repo) — not http(s)', () => {
|
|
49
|
+
expect(buildRepoBrowseUrl('git@github.com:KoalaOps/deployment.git')).toBe(null)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns null for ssh:// scheme', () => {
|
|
53
|
+
expect(buildRepoBrowseUrl('ssh://git@github.com/KoalaOps/deployment.git')).toBe(null)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns null for git+ssh:// scheme', () => {
|
|
57
|
+
expect(buildRepoBrowseUrl('git+ssh://git@github.com/o/r.git')).toBe(null)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns null for oci:// scheme (Helm OCI registry)', () => {
|
|
61
|
+
expect(buildRepoBrowseUrl('oci://registry.example.com/charts/nginx')).toBe(null)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns null for file:// scheme', () => {
|
|
65
|
+
expect(buildRepoBrowseUrl('file:///tmp/repo')).toBe(null)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns null for javascript: scheme (XSS-safe)', () => {
|
|
69
|
+
expect(buildRepoBrowseUrl('javascript:alert(1)')).toBe(null)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns null for empty / nullish input', () => {
|
|
73
|
+
expect(buildRepoBrowseUrl('')).toBe(null)
|
|
74
|
+
expect(buildRepoBrowseUrl(undefined)).toBe(null)
|
|
75
|
+
expect(buildRepoBrowseUrl(null)).toBe(null)
|
|
76
|
+
expect(buildRepoBrowseUrl(' ')).toBe(null)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns null for non-URL garbage', () => {
|
|
80
|
+
expect(buildRepoBrowseUrl('not a url')).toBe(null)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('buildPathBrowseUrl - GitHub', () => {
|
|
85
|
+
it('builds tree URL with branch ref', () => {
|
|
86
|
+
expect(
|
|
87
|
+
buildPathBrowseUrl(
|
|
88
|
+
'https://github.com/KoalaOps/deployment',
|
|
89
|
+
'argocd/addons/keda/keda/nonprod-cluster-us-east1',
|
|
90
|
+
'main'
|
|
91
|
+
)
|
|
92
|
+
).toBe(
|
|
93
|
+
'https://github.com/KoalaOps/deployment/tree/main/argocd/addons/keda/keda/nonprod-cluster-us-east1'
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('uses HEAD when targetRevision is empty', () => {
|
|
98
|
+
expect(
|
|
99
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', '')
|
|
100
|
+
).toBe('https://github.com/o/r/tree/HEAD/src')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('uses HEAD when targetRevision is literally "HEAD"', () => {
|
|
104
|
+
expect(
|
|
105
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', 'HEAD')
|
|
106
|
+
).toBe('https://github.com/o/r/tree/HEAD/src')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('passes SHA through as the ref (no special prefix)', () => {
|
|
110
|
+
const sha = 'a'.repeat(40)
|
|
111
|
+
expect(
|
|
112
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', sha)
|
|
113
|
+
).toBe(`https://github.com/o/r/tree/${sha}/src`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('works with .git suffix on repo URL', () => {
|
|
117
|
+
expect(
|
|
118
|
+
buildPathBrowseUrl('https://github.com/o/r.git', 'a/b', 'main')
|
|
119
|
+
).toBe('https://github.com/o/r/tree/main/a/b')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('url-encodes path segments with spaces', () => {
|
|
123
|
+
expect(
|
|
124
|
+
buildPathBrowseUrl('https://github.com/o/r', 'dir with space/file', 'main')
|
|
125
|
+
).toBe('https://github.com/o/r/tree/main/dir%20with%20space/file')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('drops empty path segments from double slashes', () => {
|
|
129
|
+
expect(
|
|
130
|
+
buildPathBrowseUrl('https://github.com/o/r', 'a//b', 'main')
|
|
131
|
+
).toBe('https://github.com/o/r/tree/main/a/b')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('preserves slashes in branch names (feature/foo)', () => {
|
|
135
|
+
expect(
|
|
136
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', 'feature/foo')
|
|
137
|
+
).toBe('https://github.com/o/r/tree/feature/foo/src')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('treats uppercase 40-hex as a SHA (no special prefix)', () => {
|
|
141
|
+
const sha = 'A'.repeat(40)
|
|
142
|
+
expect(
|
|
143
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', sha)
|
|
144
|
+
).toBe(`https://github.com/o/r/tree/${sha}/src`)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('buildPathBrowseUrl - GitLab', () => {
|
|
149
|
+
it('builds /-/tree URL', () => {
|
|
150
|
+
expect(
|
|
151
|
+
buildPathBrowseUrl('https://gitlab.com/group/proj', 'src/app', 'main')
|
|
152
|
+
).toBe('https://gitlab.com/group/proj/-/tree/main/src/app')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('supports nested subgroups (full group path before /-/tree)', () => {
|
|
156
|
+
expect(
|
|
157
|
+
buildPathBrowseUrl('https://gitlab.com/group/sub/proj', 'src', 'main')
|
|
158
|
+
).toBe('https://gitlab.com/group/sub/proj/-/tree/main/src')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('buildPathBrowseUrl - Bitbucket', () => {
|
|
163
|
+
it('builds /src URL with explicit ref', () => {
|
|
164
|
+
expect(
|
|
165
|
+
buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', 'develop')
|
|
166
|
+
).toBe('https://bitbucket.org/team/proj/src/develop/src/app')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Bitbucket Cloud /src/ doesn't accept "HEAD" — better no link than a 404 link.
|
|
170
|
+
it('returns null when ref is empty (HEAD not a valid Bitbucket ref token)', () => {
|
|
171
|
+
expect(
|
|
172
|
+
buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', '')
|
|
173
|
+
).toBe(null)
|
|
174
|
+
})
|
|
175
|
+
it('returns null when ref is literally "HEAD"', () => {
|
|
176
|
+
expect(
|
|
177
|
+
buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', 'HEAD')
|
|
178
|
+
).toBe(null)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('buildPathBrowseUrl - Azure DevOps', () => {
|
|
183
|
+
it('builds dev.azure.com URL with GB prefix for branches', () => {
|
|
184
|
+
expect(
|
|
185
|
+
buildPathBrowseUrl(
|
|
186
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
187
|
+
'src/app',
|
|
188
|
+
'main'
|
|
189
|
+
)
|
|
190
|
+
).toBe(
|
|
191
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src/app&version=GBmain'
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('uses GC prefix for SHA refs (lowercase)', () => {
|
|
196
|
+
const sha = 'a'.repeat(40)
|
|
197
|
+
expect(
|
|
198
|
+
buildPathBrowseUrl(
|
|
199
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
200
|
+
'src',
|
|
201
|
+
sha
|
|
202
|
+
)
|
|
203
|
+
).toBe(
|
|
204
|
+
`https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GC${sha}`
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('uses GC prefix for SHA refs (uppercase)', () => {
|
|
209
|
+
const sha = 'A'.repeat(40)
|
|
210
|
+
expect(
|
|
211
|
+
buildPathBrowseUrl(
|
|
212
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
213
|
+
'src',
|
|
214
|
+
sha
|
|
215
|
+
)
|
|
216
|
+
).toBe(
|
|
217
|
+
`https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GC${sha}`
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('percent-encodes slashes in branch names (query-string context)', () => {
|
|
222
|
+
expect(
|
|
223
|
+
buildPathBrowseUrl(
|
|
224
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
225
|
+
'src',
|
|
226
|
+
'feature/foo'
|
|
227
|
+
)
|
|
228
|
+
).toBe(
|
|
229
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBfeature%2Ffoo'
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('omits version when ref is HEAD/empty', () => {
|
|
234
|
+
expect(
|
|
235
|
+
buildPathBrowseUrl(
|
|
236
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
237
|
+
'src',
|
|
238
|
+
'HEAD'
|
|
239
|
+
)
|
|
240
|
+
).toBe('https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('supports legacy visualstudio.com host', () => {
|
|
244
|
+
expect(
|
|
245
|
+
buildPathBrowseUrl(
|
|
246
|
+
'https://myorg.visualstudio.com/MyProject/_git/myrepo',
|
|
247
|
+
'src',
|
|
248
|
+
'main'
|
|
249
|
+
)
|
|
250
|
+
).toBe(
|
|
251
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBmain'
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('does not double-encode project/repo segments that arrived percent-encoded', () => {
|
|
256
|
+
expect(
|
|
257
|
+
buildPathBrowseUrl(
|
|
258
|
+
'https://dev.azure.com/myorg/My%20Project/_git/My%20Repo',
|
|
259
|
+
'src',
|
|
260
|
+
'main'
|
|
261
|
+
)
|
|
262
|
+
).toBe(
|
|
263
|
+
'https://dev.azure.com/myorg/My%20Project/_git/My%20Repo?path=/src&version=GBmain'
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('buildPathBrowseUrl - unknown / no path', () => {
|
|
269
|
+
it('returns null for unknown hosts', () => {
|
|
270
|
+
expect(
|
|
271
|
+
buildPathBrowseUrl('https://git.internal.example.com/team/proj', 'src', 'main')
|
|
272
|
+
).toBe(null)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('returns null when path is empty', () => {
|
|
276
|
+
expect(buildPathBrowseUrl('https://github.com/o/r', '', 'main')).toBe(null)
|
|
277
|
+
expect(buildPathBrowseUrl('https://github.com/o/r', ' ', 'main')).toBe(null)
|
|
278
|
+
expect(buildPathBrowseUrl('https://github.com/o/r', null, 'main')).toBe(null)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('returns null when path has only slashes', () => {
|
|
282
|
+
expect(buildPathBrowseUrl('https://github.com/o/r', '///', 'main')).toBe(null)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('returns null for nullish repo URL', () => {
|
|
286
|
+
expect(buildPathBrowseUrl(undefined, 'src', 'main')).toBe(null)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// Known host but path too short to identify owner+repo -> downgrade to unknown.
|
|
290
|
+
// Pins the `pathParts.length < 2` guard across all three slash-providers.
|
|
291
|
+
it('returns null when github URL has only owner segment', () => {
|
|
292
|
+
expect(buildPathBrowseUrl('https://github.com/onlyowner', 'src', 'main')).toBe(null)
|
|
293
|
+
})
|
|
294
|
+
it('returns null when bitbucket URL has only owner segment', () => {
|
|
295
|
+
expect(buildPathBrowseUrl('https://bitbucket.org/onlyowner', 'src', 'main')).toBe(null)
|
|
296
|
+
})
|
|
297
|
+
it('returns null when gitlab URL has only one segment', () => {
|
|
298
|
+
expect(buildPathBrowseUrl('https://gitlab.com/onlyone', 'src', 'main')).toBe(null)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// Azure DevOps `_git` index arithmetic — high-risk branch.
|
|
302
|
+
it('returns null when dev.azure.com URL is missing _git', () => {
|
|
303
|
+
expect(
|
|
304
|
+
buildPathBrowseUrl('https://dev.azure.com/myorg/MyProject/myrepo', 'src', 'main')
|
|
305
|
+
).toBe(null)
|
|
306
|
+
})
|
|
307
|
+
it('returns null when dev.azure.com URL ends with _git (no repo segment)', () => {
|
|
308
|
+
expect(
|
|
309
|
+
buildPathBrowseUrl('https://dev.azure.com/myorg/MyProject/_git', 'src', 'main')
|
|
310
|
+
).toBe(null)
|
|
311
|
+
})
|
|
312
|
+
it('returns null when dev.azure.com URL has _git but no project (gitIdx < 2)', () => {
|
|
313
|
+
expect(
|
|
314
|
+
buildPathBrowseUrl('https://dev.azure.com/myorg/_git/myrepo', 'src', 'main')
|
|
315
|
+
).toBe(null)
|
|
316
|
+
})
|
|
317
|
+
it('returns null when visualstudio.com URL is missing _git', () => {
|
|
318
|
+
expect(
|
|
319
|
+
buildPathBrowseUrl('https://myorg.visualstudio.com/MyProject/myrepo', 'src', 'main')
|
|
320
|
+
).toBe(null)
|
|
321
|
+
})
|
|
322
|
+
it('returns null when visualstudio.com URL ends with _git', () => {
|
|
323
|
+
expect(
|
|
324
|
+
buildPathBrowseUrl('https://myorg.visualstudio.com/MyProject/_git', 'src', 'main')
|
|
325
|
+
).toBe(null)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Pin .toUpperCase() behavior so a refactor to === 'HEAD' would fail loudly.
|
|
329
|
+
it('treats lowercase "head" the same as "HEAD" (falls back to default branch)', () => {
|
|
330
|
+
expect(
|
|
331
|
+
buildPathBrowseUrl('https://github.com/o/r', 'src', 'head')
|
|
332
|
+
).toBe('https://github.com/o/r/tree/HEAD/src')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// Pin SHA_RE's exact-40-char requirement. Loosening to {7,40} would mis-prefix
|
|
336
|
+
// short-hex branch names like "abc1234" as Azure commits (GC) instead of branches (GB).
|
|
337
|
+
it('does not treat 7-char hex as a SHA on Azure (stays GB)', () => {
|
|
338
|
+
expect(
|
|
339
|
+
buildPathBrowseUrl(
|
|
340
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo',
|
|
341
|
+
'src',
|
|
342
|
+
'abc1234'
|
|
343
|
+
)
|
|
344
|
+
).toBe(
|
|
345
|
+
'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBabc1234'
|
|
346
|
+
)
|
|
347
|
+
})
|
|
348
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
type ParsedRepo =
|
|
2
|
+
| { provider: 'github' | 'gitlab' | 'bitbucket'; owner: string; repo: string }
|
|
3
|
+
| { provider: 'azure-devops'; org: string; project: string; repo: string }
|
|
4
|
+
| { provider: 'unknown' }
|
|
5
|
+
|
|
6
|
+
const SHA_RE = /^[0-9a-f]{40}$/i
|
|
7
|
+
|
|
8
|
+
function stripDotGit(s: string): string {
|
|
9
|
+
return s.replace(/\.git$/i, '')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function encodePath(path: string): string {
|
|
13
|
+
return path
|
|
14
|
+
.split('/')
|
|
15
|
+
.filter(seg => seg.length > 0)
|
|
16
|
+
.map(encodeURIComponent)
|
|
17
|
+
.join('/')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Refs (branches) can contain slashes — feature/foo — which providers serve as
|
|
21
|
+
// literal path segments. encodeURIComponent would turn those into %2F and 404.
|
|
22
|
+
function encodeRef(ref: string): string {
|
|
23
|
+
return ref.split('/').map(encodeURIComponent).join('/')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Validate `repoURL` is a well-formed http(s) URL and return the URL object.
|
|
27
|
+
// SCP-form (git@host:o/r), ssh://, git+ssh://, oci://, file://, etc. all
|
|
28
|
+
// return null — we don't manipulate user-supplied URLs to make them linkable.
|
|
29
|
+
function parseHttpRepoUrl(repoURL: string | undefined | null): URL | null {
|
|
30
|
+
if (!repoURL) return null
|
|
31
|
+
const trimmed = repoURL.trim()
|
|
32
|
+
if (!trimmed) return null
|
|
33
|
+
let url: URL
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(trimmed)
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
40
|
+
return url
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detectProvider(url: URL): ParsedRepo {
|
|
44
|
+
const hostname = url.hostname.toLowerCase()
|
|
45
|
+
const pathParts = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
|
|
46
|
+
|
|
47
|
+
if (hostname === 'github.com' || hostname === 'bitbucket.org') {
|
|
48
|
+
if (pathParts.length < 2) return { provider: 'unknown' }
|
|
49
|
+
return {
|
|
50
|
+
provider: hostname === 'github.com' ? 'github' : 'bitbucket',
|
|
51
|
+
owner: pathParts[0],
|
|
52
|
+
repo: stripDotGit(pathParts[1]),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (hostname === 'gitlab.com') {
|
|
57
|
+
// GitLab supports nested groups: the owner segment may itself be a slash-joined path.
|
|
58
|
+
if (pathParts.length < 2) return { provider: 'unknown' }
|
|
59
|
+
return {
|
|
60
|
+
provider: 'gitlab',
|
|
61
|
+
owner: pathParts.slice(0, -1).join('/'),
|
|
62
|
+
repo: stripDotGit(pathParts[pathParts.length - 1]),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Azure DevOps URL shape: /{org}/{project}/_git/{repo} (dev.azure.com)
|
|
67
|
+
// or {org}.visualstudio.com/{project}/_git/{repo} (legacy).
|
|
68
|
+
if (hostname === 'dev.azure.com') {
|
|
69
|
+
const gitIdx = pathParts.indexOf('_git')
|
|
70
|
+
if (gitIdx < 2 || gitIdx + 1 >= pathParts.length) {
|
|
71
|
+
return { provider: 'unknown' }
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
provider: 'azure-devops',
|
|
75
|
+
org: pathParts.slice(0, gitIdx - 1).join('/'),
|
|
76
|
+
project: pathParts[gitIdx - 1],
|
|
77
|
+
repo: pathParts[gitIdx + 1],
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (hostname.endsWith('.visualstudio.com')) {
|
|
82
|
+
const gitIdx = pathParts.indexOf('_git')
|
|
83
|
+
if (gitIdx < 1 || gitIdx + 1 >= pathParts.length) {
|
|
84
|
+
return { provider: 'unknown' }
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
provider: 'azure-devops',
|
|
88
|
+
org: hostname.slice(0, -'.visualstudio.com'.length),
|
|
89
|
+
project: pathParts.slice(0, gitIdx).join('/'),
|
|
90
|
+
repo: pathParts[gitIdx + 1],
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { provider: 'unknown' }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Pass-through linkability check: link the user's repoURL as-is iff it parses
|
|
98
|
+
// as http(s); never rewrite SCP/SSH forms.
|
|
99
|
+
export function buildRepoBrowseUrl(repoURL: string | undefined | null): string | null {
|
|
100
|
+
return parseHttpRepoUrl(repoURL) ? repoURL!.trim() : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildPathBrowseUrl(
|
|
104
|
+
repoURL: string | undefined | null,
|
|
105
|
+
path: string | undefined | null,
|
|
106
|
+
targetRevision: string | undefined | null
|
|
107
|
+
): string | null {
|
|
108
|
+
if (!path || !path.trim()) return null
|
|
109
|
+
const url = parseHttpRepoUrl(repoURL)
|
|
110
|
+
if (!url) return null
|
|
111
|
+
const parsed = detectProvider(url)
|
|
112
|
+
if (parsed.provider === 'unknown') return null
|
|
113
|
+
|
|
114
|
+
const rawRef = (targetRevision ?? '').trim()
|
|
115
|
+
// GitHub and GitLab browse URLs accept "HEAD" as a ref token that resolves
|
|
116
|
+
// to the default branch. Bitbucket Cloud's /src/ path does not — see the
|
|
117
|
+
// bitbucket case below.
|
|
118
|
+
const hasExplicitRef = rawRef !== '' && rawRef.toUpperCase() !== 'HEAD'
|
|
119
|
+
const ref = hasExplicitRef ? rawRef : 'HEAD'
|
|
120
|
+
const encodedPath = encodePath(path)
|
|
121
|
+
if (!encodedPath) return null
|
|
122
|
+
|
|
123
|
+
switch (parsed.provider) {
|
|
124
|
+
case 'github':
|
|
125
|
+
return `https://github.com/${parsed.owner}/${parsed.repo}/tree/${encodeRef(ref)}/${encodedPath}`
|
|
126
|
+
case 'gitlab':
|
|
127
|
+
return `https://gitlab.com/${parsed.owner}/${parsed.repo}/-/tree/${encodeRef(ref)}/${encodedPath}`
|
|
128
|
+
case 'bitbucket':
|
|
129
|
+
// Bitbucket Cloud's /src/{ref}/... endpoint requires a real branch name
|
|
130
|
+
// or commit hash; HEAD 404s. Without an explicit ref we can't build a
|
|
131
|
+
// working deep link, so fall through to plain text.
|
|
132
|
+
if (!hasExplicitRef) return null
|
|
133
|
+
return `https://bitbucket.org/${parsed.owner}/${parsed.repo}/src/${encodeRef(ref)}/${encodedPath}`
|
|
134
|
+
case 'azure-devops': {
|
|
135
|
+
const isSha = hasExplicitRef && SHA_RE.test(rawRef)
|
|
136
|
+
const versionParam = hasExplicitRef ? `&version=${isSha ? 'GC' : 'GB'}${encodeURIComponent(rawRef)}` : ''
|
|
137
|
+
// org/project/repo are taken straight from url.pathname segments, which the URL
|
|
138
|
+
// parser leaves percent-encoded — re-encoding would double-encode (My%20X → My%2520X).
|
|
139
|
+
return `https://dev.azure.com/${parsed.org}/${parsed.project}/_git/${parsed.repo}?path=/${encodedPath}${versionParam}`
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/utils/index.ts
CHANGED