@skyhook-io/k8s-ui 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/PrometheusChartsView.tsx +233 -0
  3. package/src/components/charts/index.ts +13 -0
  4. package/src/components/checks/ChecksView.tsx +5 -1
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +4 -4
  6. package/src/components/gitops/GitOpsTableView.tsx +203 -6
  7. package/src/components/gitops/index.ts +1 -0
  8. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
  9. package/src/components/issues/IssuesView.tsx +352 -0
  10. package/src/components/issues/index.ts +24 -0
  11. package/src/components/issues/issues.test.ts +100 -0
  12. package/src/components/issues/severity.ts +125 -0
  13. package/src/components/issues/types.ts +194 -0
  14. package/src/components/resources/ResourcesView.tsx +27 -25
  15. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
  16. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
  19. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
  20. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  21. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
  22. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
  25. package/src/components/resources/renderers/PodRenderer.tsx +7 -1
  26. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
  28. package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
  29. package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
  30. package/src/components/resources/resource-utils-argo.ts +18 -0
  31. package/src/components/shared/DetailShell.tsx +107 -0
  32. package/src/components/shared/ManagedByChip.tsx +149 -16
  33. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  34. package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
  35. package/src/components/shared/index.ts +2 -1
  36. package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
  37. package/src/components/timeline/index.ts +1 -0
  38. package/src/components/topology/K8sResourceNode.tsx +60 -60
  39. package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
  40. package/src/components/topology/TopologyGraph.tsx +168 -52
  41. package/src/components/topology/TopologySearch.tsx +17 -8
  42. package/src/components/topology/topology-search-match.test.ts +1 -1
  43. package/src/components/topology/topology.css +18 -0
  44. package/src/components/ui/RowActionMenu.tsx +149 -0
  45. package/src/components/ui/Tooltip.tsx +37 -2
  46. package/src/components/ui/index.ts +2 -0
  47. package/src/components/workload/WorkloadView.tsx +118 -112
  48. package/src/index.ts +5 -0
  49. package/src/theme/components.css +16 -0
  50. package/src/types/core.ts +3 -2
  51. package/src/utils/env-from.ts +3 -0
  52. package/src/utils/git-provider-urls.test.ts +348 -0
  53. package/src/utils/git-provider-urls.ts +142 -0
  54. package/src/utils/index.ts +2 -0
  55. package/src/utils/replica-scalers.ts +9 -0
  56. package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
@@ -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
+ }
@@ -8,6 +8,7 @@ export * from './animation'
8
8
  export * from './resource-hierarchy'
9
9
  export * from './log-format'
10
10
  export * from './download'
11
+ export * from './env-from'
11
12
  export * from './api-resources'
12
13
  export * from './skeleton-yaml'
13
14
  export * from './k8s-errors'
@@ -17,3 +18,4 @@ export * from './validators'
17
18
  export * from './gitops-owner'
18
19
  export * from './gitops-route'
19
20
  export * from './rbac-badges'
21
+ export * from './git-provider-urls'
@@ -0,0 +1,9 @@
1
+ import type { ResourceRef } from '../types'
2
+
3
+ export function replicaScalers(scalers?: ResourceRef[]): ResourceRef[] | undefined {
4
+ const result = scalers?.filter((ref) => {
5
+ const kind = ref.kind.toLowerCase()
6
+ return kind === 'horizontalpodautoscaler' || kind === 'scaledobject'
7
+ })
8
+ return result && result.length > 0 ? result : undefined
9
+ }
@@ -1,85 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { pluralize } from '../../utils/pluralize'
3
- import type { SelectedKindInfo } from './ResourcesSidebar'
4
-
5
- // Mirrors the empty-state hint rule inside ResourcesView (kept in
6
- // sync by reuse) — pure so we can pin the lookup + render contract
7
- // without rendering the React component. See
8
- // `packages/k8s-ui/src/components/resources/ResourcesView.tsx`
9
- // search for "the count is unfiltered".
10
- function sidebarHint(
11
- counts: Record<string, number>,
12
- selectedKind: SelectedKindInfo,
13
- searchTerm: string,
14
- ): string | null {
15
- if (!searchTerm) return null
16
- const key = selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind
17
- const totalForKind = counts[key] ?? 0
18
- if (totalForKind === 0) return null
19
- return `The sidebar shows ${pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.`
20
- }
21
-
22
- describe('ResourcesView empty-search sidebar hint (SKY-828 bug 46)', () => {
23
- // The bug: sidebar count badge stays unfiltered and shows the
24
- // cluster-wide total per kind. When a search returns zero rows
25
- // (e.g. "5 Pods" in sidebar, "No Pods found" in pane) the user
26
- // reads the badge as a lie. The empty-state appends a sentence
27
- // making the unfiltered nature explicit. The rule must:
28
- // 1. Suppress entirely when there's no active search.
29
- // 2. Suppress when the cluster total for the kind is 0
30
- // (no badge to explain → no hint).
31
- // 3. Look up the count under `${group}/${kind}` for grouped
32
- // kinds, plain `kind` for core kinds (matches sidebar key).
33
- // 4. Pluralize via pluralize() so "Ingress" → "Ingresses",
34
- // "NetworkPolicy" → "NetworkPolicies" (regression of the
35
- // naive `${kind}s` Cursor Bugbot caught in 80eb64b).
36
- // 5. Singular for n===1: "1 Pod", not "1 Pods".
37
-
38
- const pod: SelectedKindInfo = { name: 'pods', kind: 'Pod', group: '' }
39
-
40
- it('returns null when there is no active search', () => {
41
- expect(sidebarHint({ Pod: 232 }, pod, '')).toBeNull()
42
- })
43
-
44
- it('returns null when the cluster total for the kind is 0', () => {
45
- expect(sidebarHint({ Pod: 0 }, pod, 'xyz')).toBeNull()
46
- })
47
-
48
- it('returns null when the kind is missing from counts', () => {
49
- expect(sidebarHint({}, pod, 'xyz')).toBeNull()
50
- })
51
-
52
- it('formats the sentence with the cluster total and pluralized kind', () => {
53
- expect(sidebarHint({ Pod: 232 }, pod, 'xyz')).toBe(
54
- 'The sidebar shows 232 Pods in the cluster — the count is unfiltered.',
55
- )
56
- })
57
-
58
- it('uses singular noun when total is 1', () => {
59
- expect(sidebarHint({ Pod: 1 }, pod, 'xyz')).toBe(
60
- 'The sidebar shows 1 Pod in the cluster — the count is unfiltered.',
61
- )
62
- })
63
-
64
- it('looks up grouped kinds under `${group}/${kind}`', () => {
65
- const ar: SelectedKindInfo = { name: 'rollouts', kind: 'Rollout', group: 'argoproj.io' }
66
- const counts = { Rollout: 999, 'argoproj.io/Rollout': 4 }
67
- expect(sidebarHint(counts, ar, 'xyz')).toBe(
68
- 'The sidebar shows 4 Rollouts in the cluster — the count is unfiltered.',
69
- )
70
- })
71
-
72
- it('pluralizes Ingress correctly (regression: not "Ingresss")', () => {
73
- const ing: SelectedKindInfo = { name: 'ingresses', kind: 'Ingress', group: '' }
74
- expect(sidebarHint({ Ingress: 3 }, ing, 'xyz')).toBe(
75
- 'The sidebar shows 3 Ingresses in the cluster — the count is unfiltered.',
76
- )
77
- })
78
-
79
- it('pluralizes NetworkPolicy correctly (regression: not "NetworkPolicys")', () => {
80
- const np: SelectedKindInfo = { name: 'networkpolicies', kind: 'NetworkPolicy', group: 'networking.k8s.io' }
81
- expect(sidebarHint({ 'networking.k8s.io/NetworkPolicy': 2 }, np, 'xyz')).toBe(
82
- 'The sidebar shows 2 NetworkPolicies in the cluster — the count is unfiltered.',
83
- )
84
- })
85
- })