@skyhook-io/k8s-ui 1.7.5 → 1.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/gitops/GitOpsDetailLayout.tsx +4 -4
- package/src/components/gitops/GitOpsTableView.tsx +363 -89
- package/src/components/gitops/index.ts +1 -0
- package/src/components/resources/ResourcesView.tsx +75 -19
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
- package/src/components/resources/renderers/RBACErrorSection.tsx +6 -9
- package/src/components/resources/renderers/RoleBindingRenderer.test.tsx +76 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +23 -7
- package/src/components/resources/resource-utils-argo.ts +18 -0
- package/src/components/ui/FetchResult.test.tsx +93 -0
- package/src/components/ui/FetchResult.tsx +141 -0
- package/src/components/ui/RowActionMenu.tsx +149 -0
- package/src/components/ui/index.ts +3 -0
- package/src/components/workload/WorkloadView.tsx +13 -18
- package/src/types/fetch-error.test.ts +46 -0
- package/src/types/fetch-error.ts +20 -0
- package/src/types/index.ts +1 -0
- package/src/utils/git-provider-urls.test.ts +348 -0
- package/src/utils/git-provider-urls.ts +142 -0
- package/src/utils/index.ts +1 -0
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { clsx } from 'clsx'
|
|
29
29
|
import { ResourceBar } from '../ui/ResourceBar'
|
|
30
30
|
import type { SelectedResource, APIResource } from '../../types'
|
|
31
|
+
import { isForbiddenError } from '../../types/fetch-error'
|
|
31
32
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
32
33
|
import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
|
|
33
34
|
import {
|
|
@@ -228,6 +229,13 @@ const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
|
|
|
228
229
|
'w-48': 192, 'w-56': 224, 'w-64': 256,
|
|
229
230
|
}
|
|
230
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
|
+
|
|
231
239
|
function getColumnMinWidth(col: Column): number {
|
|
232
240
|
if (col.minWidth) return col.minWidth
|
|
233
241
|
if (!col.width) return 200 // Name column (no width class) gets wider minimum
|
|
@@ -1681,11 +1689,6 @@ interface ResourcesViewData {
|
|
|
1681
1689
|
|
|
1682
1690
|
export const ResourcesViewDataContext = React.createContext<ResourcesViewData>({})
|
|
1683
1691
|
|
|
1684
|
-
// Inline helper replacing ApiError/isForbiddenError from the removed api/client import
|
|
1685
|
-
function isForbiddenError(error: any): boolean {
|
|
1686
|
-
return error?.status === 403
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
1692
|
export interface ResourceQueryResult {
|
|
1690
1693
|
data?: any[]
|
|
1691
1694
|
isLoading: boolean
|
|
@@ -1756,6 +1759,15 @@ interface ResourcesViewProps {
|
|
|
1756
1759
|
* should also wire onResourceClick to handle the deep-link-on-load
|
|
1757
1760
|
* case. */
|
|
1758
1761
|
onRowSelect?: (resource: any) => void
|
|
1762
|
+
/**
|
|
1763
|
+
* When provided, the name cell renders as a real `<a href>` instead of
|
|
1764
|
+
* relying on per-cell click handlers for navigation. Restores ⌘-click /
|
|
1765
|
+
* middle-click / "Copy link" / hover URL preview / screen-reader link
|
|
1766
|
+
* semantics. Hosts using full-page navigation should prefer this over
|
|
1767
|
+
* `onRowSelect`; the anchor will own navigation and the rest of the row
|
|
1768
|
+
* remains clickable for selection (drawer open).
|
|
1769
|
+
*/
|
|
1770
|
+
rowHrefFor?: (resource: any) => string
|
|
1759
1771
|
/**
|
|
1760
1772
|
* Overrides the default compare-mode submit (which navigates to
|
|
1761
1773
|
* `/compare?kind=...&a=...&b=...`). Hosts use this to route to a
|
|
@@ -1927,6 +1939,7 @@ export function ResourcesView({
|
|
|
1927
1939
|
defaultKind = DEFAULT_KIND_INFO,
|
|
1928
1940
|
extraLeadingColumns,
|
|
1929
1941
|
onRowSelect,
|
|
1942
|
+
rowHrefFor,
|
|
1930
1943
|
onCompareSubmit,
|
|
1931
1944
|
resolveRowCluster,
|
|
1932
1945
|
onClearNamespaces,
|
|
@@ -3361,6 +3374,18 @@ export function ResourcesView({
|
|
|
3361
3374
|
return allColumns.filter(c => visibleColumns.has(c.key))
|
|
3362
3375
|
}, [allColumns, visibleColumns])
|
|
3363
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
|
+
|
|
3364
3389
|
// Stable virtuoso components — memoized to avoid remounting the table on every render
|
|
3365
3390
|
const virtuosoComponents = useMemo(() => ({
|
|
3366
3391
|
Table: React.forwardRef<HTMLTableElement, React.TableHTMLAttributes<HTMLTableElement>>(function VirtuosoTable(props, ref) {
|
|
@@ -3369,7 +3394,7 @@ export function ResourcesView({
|
|
|
3369
3394
|
{...props}
|
|
3370
3395
|
ref={ref}
|
|
3371
3396
|
className="w-full"
|
|
3372
|
-
style={{ ...props.style, tableLayout: 'fixed' }}
|
|
3397
|
+
style={{ ...props.style, tableLayout: 'fixed', minWidth: tableMinWidth }}
|
|
3373
3398
|
>
|
|
3374
3399
|
<colgroup>
|
|
3375
3400
|
{/*
|
|
@@ -3379,7 +3404,7 @@ export function ResourcesView({
|
|
|
3379
3404
|
the missing entry by stealing width from a sized neighbour
|
|
3380
3405
|
— typically blowing this narrow column out to ~200px.
|
|
3381
3406
|
*/}
|
|
3382
|
-
{compareMode && <col style={{ width:
|
|
3407
|
+
{compareMode && <col style={{ width: COMPARE_COLUMN_WIDTH }} />}
|
|
3383
3408
|
{columns.map(col => (
|
|
3384
3409
|
<col
|
|
3385
3410
|
key={col.key}
|
|
@@ -3397,7 +3422,7 @@ export function ResourcesView({
|
|
|
3397
3422
|
)
|
|
3398
3423
|
}),
|
|
3399
3424
|
TableRow: VirtuosoTableRow,
|
|
3400
|
-
}), [columns, columnWidths, hasResizedColumns, compareMode])
|
|
3425
|
+
}), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth])
|
|
3401
3426
|
|
|
3402
3427
|
// Calculate filter options with counts based on current resources (before filtering)
|
|
3403
3428
|
const filterOptions = useMemo(() => {
|
|
@@ -3943,7 +3968,7 @@ export function ResourcesView({
|
|
|
3943
3968
|
|
|
3944
3969
|
{/* Table */}
|
|
3945
3970
|
<div
|
|
3946
|
-
className="flex-1 overflow-
|
|
3971
|
+
className="flex-1 overflow-auto relative"
|
|
3947
3972
|
ref={tableContainerRef}
|
|
3948
3973
|
onClick={(e) => {
|
|
3949
3974
|
if (e.target === e.currentTarget && selectedResource) {
|
|
@@ -4056,7 +4081,7 @@ export function ResourcesView({
|
|
|
4056
4081
|
// Inline px width — under `table-layout:fixed`,
|
|
4057
4082
|
// `w-9` is a hint the browser absorbs into leftover
|
|
4058
4083
|
// row width on an icon-only column.
|
|
4059
|
-
style={
|
|
4084
|
+
style={COMPARE_COLUMN_STYLE}
|
|
4060
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"
|
|
4061
4086
|
title="Compare mode"
|
|
4062
4087
|
>
|
|
@@ -4256,6 +4281,7 @@ export function ResourcesView({
|
|
|
4256
4281
|
onMouseEnter={() => setHighlightedIndex(-1)}
|
|
4257
4282
|
compareMode={compareMode}
|
|
4258
4283
|
comparePickIndex={pickIdx}
|
|
4284
|
+
rowHref={rowHrefFor?.(resource)}
|
|
4259
4285
|
/>
|
|
4260
4286
|
)
|
|
4261
4287
|
}}
|
|
@@ -4301,6 +4327,10 @@ interface ResourceRowCellsProps {
|
|
|
4301
4327
|
compareMode?: boolean
|
|
4302
4328
|
/** -1 when not picked; 0 = pick A; 1 = pick B. */
|
|
4303
4329
|
comparePickIndex?: number
|
|
4330
|
+
/** When provided, the name cell renders as `<a href>` and the other
|
|
4331
|
+
* data cells drop their click handlers. The compare-mode chip column
|
|
4332
|
+
* is unaffected (still toggles picks). */
|
|
4333
|
+
rowHref?: string
|
|
4304
4334
|
}
|
|
4305
4335
|
|
|
4306
4336
|
function rowHighlightClass(
|
|
@@ -4320,16 +4350,20 @@ function rowHighlightClass(
|
|
|
4320
4350
|
return 'group-hover/row:bg-theme-surface/50'
|
|
4321
4351
|
}
|
|
4322
4352
|
|
|
4323
|
-
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1 }: ResourceRowCellsProps) {
|
|
4353
|
+
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter, compareMode, comparePickIndex = -1, rowHref }: ResourceRowCellsProps) {
|
|
4324
4354
|
const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted)
|
|
4325
4355
|
const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
|
|
4356
|
+
// When the host supplies an anchor, drop per-cell onClick for the data
|
|
4357
|
+
// columns: the anchor is the only navigation surface. The compare-mode
|
|
4358
|
+
// chip column keeps its onClick so pick toggling still works.
|
|
4359
|
+
const cellsAreClickable = !rowHref
|
|
4326
4360
|
return (
|
|
4327
4361
|
<>
|
|
4328
4362
|
{compareMode && (
|
|
4329
4363
|
<td
|
|
4330
4364
|
onClick={onClick}
|
|
4331
4365
|
onMouseEnter={onMouseEnter}
|
|
4332
|
-
style={
|
|
4366
|
+
style={COMPARE_COLUMN_STYLE}
|
|
4333
4367
|
className={clsx('px-2 py-3 border-b-subtle cursor-pointer text-center align-middle transition-colors', rowHighlight)}
|
|
4334
4368
|
>
|
|
4335
4369
|
{pickedSide ? (
|
|
@@ -4353,15 +4387,24 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
|
|
|
4353
4387
|
{columns.map((col) => (
|
|
4354
4388
|
<td
|
|
4355
4389
|
key={col.key}
|
|
4356
|
-
onClick={onClick}
|
|
4390
|
+
onClick={cellsAreClickable ? onClick : undefined}
|
|
4357
4391
|
onMouseEnter={onMouseEnter}
|
|
4358
4392
|
className={clsx(
|
|
4359
|
-
'px-4 py-3 border-b-subtle
|
|
4393
|
+
'px-4 py-3 border-b-subtle transition-colors',
|
|
4394
|
+
cellsAreClickable && 'cursor-pointer',
|
|
4360
4395
|
col.key !== 'status' && 'overflow-hidden truncate',
|
|
4361
4396
|
rowHighlight,
|
|
4362
4397
|
)}
|
|
4363
4398
|
>
|
|
4364
|
-
<CellContent
|
|
4399
|
+
<CellContent
|
|
4400
|
+
resource={resource}
|
|
4401
|
+
kind={kind}
|
|
4402
|
+
group={group}
|
|
4403
|
+
column={col.key}
|
|
4404
|
+
majorityNodeMinorVersion={majorityNodeMinorVersion}
|
|
4405
|
+
extraColumn={extraColumnsByKey?.get(col.key)}
|
|
4406
|
+
nameHref={col.key === 'name' ? rowHref : undefined}
|
|
4407
|
+
/>
|
|
4365
4408
|
</td>
|
|
4366
4409
|
))}
|
|
4367
4410
|
{hasSpacerColumn && <td className="border-b-subtle p-0" />}
|
|
@@ -4405,9 +4448,12 @@ interface CellContentProps {
|
|
|
4405
4448
|
* column key. Render via the extra's render() and short-circuit
|
|
4406
4449
|
* the built-in cell logic. */
|
|
4407
4450
|
extraColumn?: ExtraColumn
|
|
4451
|
+
/** When set on the name column, the resource name renders as `<a href>`
|
|
4452
|
+
* so ⌘-click / copy-link / hover-URL all work. */
|
|
4453
|
+
nameHref?: string
|
|
4408
4454
|
}
|
|
4409
4455
|
|
|
4410
|
-
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn }: CellContentProps) {
|
|
4456
|
+
function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn, nameHref }: CellContentProps) {
|
|
4411
4457
|
// Parent-injected extra columns short-circuit the built-in switch.
|
|
4412
4458
|
// Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
|
|
4413
4459
|
if (extraColumn) {
|
|
@@ -4419,12 +4465,22 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
|
|
|
4419
4465
|
// Common columns
|
|
4420
4466
|
if (column === 'name') {
|
|
4421
4467
|
const isTerminating = !!meta.deletionTimestamp
|
|
4468
|
+
const nameClass = clsx('text-sm font-medium truncate block', isTerminating ? 'text-theme-text-tertiary line-through' : 'text-theme-text-primary')
|
|
4422
4469
|
return (
|
|
4423
4470
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
4424
4471
|
<Tooltip content={meta.name}>
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4472
|
+
{nameHref ? (
|
|
4473
|
+
<a
|
|
4474
|
+
href={nameHref}
|
|
4475
|
+
className={clsx(nameClass, 'hover:underline focus-visible:underline focus-visible:outline-none rounded-sm')}
|
|
4476
|
+
>
|
|
4477
|
+
{meta.name}
|
|
4478
|
+
</a>
|
|
4479
|
+
) : (
|
|
4480
|
+
<span className={nameClass}>
|
|
4481
|
+
{meta.name}
|
|
4482
|
+
</span>
|
|
4483
|
+
)}
|
|
4428
4484
|
</Tooltip>
|
|
4429
4485
|
<CopyNameButton name={meta.name} />
|
|
4430
4486
|
{isTerminating && (
|
|
@@ -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"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Shield } from 'lucide-react'
|
|
2
2
|
import type { ComponentType } from 'react'
|
|
3
3
|
import { Section } from '../../ui/drawer-components'
|
|
4
|
+
import { isFetchError, isForbiddenError } from '../../../types/fetch-error'
|
|
4
5
|
|
|
5
6
|
interface RBACErrorSectionProps {
|
|
6
7
|
title: string
|
|
@@ -13,24 +14,20 @@ interface RBACErrorSectionProps {
|
|
|
13
14
|
errorPrefix?: string
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
const errorStatus = (error: Error): number | undefined => (error as { status?: number }).status
|
|
17
|
-
|
|
18
17
|
// 503 because Radar's SA can't read RBAC, so the informers never synced — a
|
|
19
18
|
// cluster-static config state (same on every resource), not a failure. The message
|
|
20
19
|
// check distinguishes it from a generic connectivity 503 ("Not connected to
|
|
21
20
|
// cluster"), which is a real fault and must stay loud (red).
|
|
22
21
|
const isRBACCacheUnavailable = (error: Error): boolean =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// 403 because the requesting user lacks list permission on bindings.
|
|
26
|
-
const isRBACForbidden = (error: Error): boolean => errorStatus(error) === 403
|
|
22
|
+
isFetchError(error) && error.status === 503 && error.message.includes('RBAC cache')
|
|
27
23
|
|
|
28
|
-
// True for the two expected, non-actionable RBAC states
|
|
24
|
+
// True for the two expected, non-actionable RBAC states. isForbiddenError (403)
|
|
25
|
+
// means the requesting user lacks list permission on bindings. Surfaces that treat
|
|
29
26
|
// the RBAC section as a bonus (Pod/Workload Permissions) hide it entirely for these.
|
|
30
27
|
// Genuine faults — connectivity 503, 500, network errors — are deliberately NOT
|
|
31
28
|
// included, so they still surface rather than being silently dropped.
|
|
32
29
|
export function isRBACUnavailable(error: Error): boolean {
|
|
33
|
-
return
|
|
30
|
+
return isForbiddenError(error) || isRBACCacheUnavailable(error)
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
// RBACErrorSection renders each expected state as a calm note (distinct copy per
|
|
@@ -53,7 +50,7 @@ export function RBACErrorSection({
|
|
|
53
50
|
</Section>
|
|
54
51
|
)
|
|
55
52
|
}
|
|
56
|
-
if (
|
|
53
|
+
if (isForbiddenError(error)) {
|
|
57
54
|
return (
|
|
58
55
|
<Section title={title} icon={icon}>
|
|
59
56
|
<div className="text-sm text-theme-text-tertiary">
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { RoleBindingRenderer } from './RoleBindingRenderer'
|
|
4
|
+
|
|
5
|
+
function shaped(message: string, status: number) {
|
|
6
|
+
return Object.assign(new Error(message), { status })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const binding = {
|
|
10
|
+
metadata: { name: 'rb-1', namespace: 'dev' },
|
|
11
|
+
roleRef: { kind: 'ClusterRole', name: 'view', apiGroup: 'rbac.authorization.k8s.io' },
|
|
12
|
+
subjects: [
|
|
13
|
+
{ kind: 'ServiceAccount', name: 'alice', namespace: 'dev' },
|
|
14
|
+
],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('RoleBindingRenderer rules section', () => {
|
|
18
|
+
it('renders rule rows when rules are present', () => {
|
|
19
|
+
const html = renderToString(
|
|
20
|
+
<RoleBindingRenderer
|
|
21
|
+
data={binding}
|
|
22
|
+
roleRules={[{ verbs: ['get', 'list'], resources: ['pods'], apiGroups: [''] }]}
|
|
23
|
+
/>,
|
|
24
|
+
)
|
|
25
|
+
expect(html).toContain('Rules Granted')
|
|
26
|
+
expect(html).toContain('pods')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders "no rules" when rules array is empty (loaded but role has none)', () => {
|
|
30
|
+
const html = renderToString(<RoleBindingRenderer data={binding} roleRules={[]} />)
|
|
31
|
+
expect(html).toContain('no rules')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders the orphan-or-unavailable fallback when rules is null and no error', () => {
|
|
35
|
+
const html = renderToString(<RoleBindingRenderer data={binding} roleRules={null} />)
|
|
36
|
+
expect(html).toContain('Could not resolve referenced role')
|
|
37
|
+
expect(html).toContain('orphan binding')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the Access denied message when rules is null and the fetch was 403', () => {
|
|
41
|
+
const html = renderToString(
|
|
42
|
+
<RoleBindingRenderer
|
|
43
|
+
data={binding}
|
|
44
|
+
roleRules={null}
|
|
45
|
+
roleRulesError={shaped('forbidden', 403)}
|
|
46
|
+
/>,
|
|
47
|
+
)
|
|
48
|
+
expect(html).toContain('Access denied reading referenced role')
|
|
49
|
+
expect(html).toContain('view')
|
|
50
|
+
expect(html).not.toContain('orphan binding')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('falls back to the orphan-or-unavailable message for non-403 errors (404, 500, network)', () => {
|
|
54
|
+
const html = renderToString(
|
|
55
|
+
<RoleBindingRenderer
|
|
56
|
+
data={binding}
|
|
57
|
+
roleRules={null}
|
|
58
|
+
roleRulesError={shaped('not found', 404)}
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
expect(html).toContain('Could not resolve referenced role')
|
|
62
|
+
expect(html).not.toContain('Access denied')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('keeps showing cached rules when a refetch fails with 403 (stale data preserved)', () => {
|
|
66
|
+
const html = renderToString(
|
|
67
|
+
<RoleBindingRenderer
|
|
68
|
+
data={binding}
|
|
69
|
+
roleRules={[{ verbs: ['get'], resources: ['secrets'], apiGroups: [''] }]}
|
|
70
|
+
roleRulesError={shaped('forbidden', 403)}
|
|
71
|
+
/>,
|
|
72
|
+
)
|
|
73
|
+
expect(html).toContain('secrets')
|
|
74
|
+
expect(html).not.toContain('Access denied')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -2,6 +2,7 @@ import { Shield, Users, Eye } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ResourceLink, AlertBanner } from '../../ui/drawer-components'
|
|
4
4
|
import type { ResourceRef, RBACPolicyRule } from '../../../types'
|
|
5
|
+
import { isForbiddenError } from '../../../types/fetch-error'
|
|
5
6
|
import {
|
|
6
7
|
rbacVerbBadgeClass,
|
|
7
8
|
rbacResourceBadgeClass,
|
|
@@ -15,9 +16,14 @@ interface RoleBindingRendererProps {
|
|
|
15
16
|
onNavigate?: (ref: ResourceRef) => void
|
|
16
17
|
/** Rules from the referenced Role/ClusterRole. Undefined means the host
|
|
17
18
|
* hasn't wired the fetch (inline rules preview is omitted). Null means
|
|
18
|
-
* the fetch
|
|
19
|
+
* the fetch finished without a resource (orphan binding); the section
|
|
20
|
+
* says so. */
|
|
19
21
|
roleRules?: RBACPolicyRule[] | null
|
|
20
22
|
roleRulesLoading?: boolean
|
|
23
|
+
/** Error from the role/clusterrole fetch. When present and shaped like
|
|
24
|
+
* a FetchErrorShape (status + message), the rules section distinguishes
|
|
25
|
+
* a 403 ("Access denied") from the orphan-or-unavailable fallback. */
|
|
26
|
+
roleRulesError?: unknown
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
// Wide groups whose membership effectively widens a binding beyond a named
|
|
@@ -57,7 +63,7 @@ function getSubjectKindBadgeClass(kind: string): string {
|
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoading }: RoleBindingRendererProps) {
|
|
66
|
+
export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoading, roleRulesError }: RoleBindingRendererProps) {
|
|
61
67
|
const roleRef = data.roleRef || {}
|
|
62
68
|
const subjects: any[] = data.subjects || []
|
|
63
69
|
const isClusterRoleBinding = data.kind === 'ClusterRoleBinding'
|
|
@@ -117,6 +123,7 @@ export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoad
|
|
|
117
123
|
<RulesPreviewSection
|
|
118
124
|
rules={roleRules}
|
|
119
125
|
loading={!!roleRulesLoading}
|
|
126
|
+
error={roleRulesError}
|
|
120
127
|
roleName={roleRef.name}
|
|
121
128
|
/>
|
|
122
129
|
)}
|
|
@@ -152,10 +159,12 @@ export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoad
|
|
|
152
159
|
function RulesPreviewSection({
|
|
153
160
|
rules,
|
|
154
161
|
loading,
|
|
162
|
+
error,
|
|
155
163
|
roleName,
|
|
156
164
|
}: {
|
|
157
165
|
rules: RBACPolicyRule[] | null
|
|
158
166
|
loading: boolean
|
|
167
|
+
error?: unknown
|
|
159
168
|
roleName?: string
|
|
160
169
|
}) {
|
|
161
170
|
return (
|
|
@@ -163,11 +172,18 @@ function RulesPreviewSection({
|
|
|
163
172
|
{loading ? (
|
|
164
173
|
<div className="text-sm text-theme-text-tertiary">Loading rules…</div>
|
|
165
174
|
) : !rules ? (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
isForbiddenError(error) ? (
|
|
176
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
177
|
+
Access denied reading referenced role
|
|
178
|
+
{roleName ? ` "${roleName}"` : ''}.
|
|
179
|
+
</div>
|
|
180
|
+
) : (
|
|
181
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
182
|
+
Could not resolve referenced role
|
|
183
|
+
{roleName ? ` "${roleName}"` : ''} — it may not exist (orphan binding)
|
|
184
|
+
or be unavailable.
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
171
187
|
) : rules.length === 0 ? (
|
|
172
188
|
<div className="text-sm text-theme-text-tertiary">
|
|
173
189
|
The referenced role has no rules.
|
|
@@ -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,93 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { FetchResult } from './FetchResult'
|
|
4
|
+
|
|
5
|
+
function shaped(message: string, status: number) {
|
|
6
|
+
return Object.assign(new Error(message), { status })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('FetchResult', () => {
|
|
10
|
+
it('renders the loader when loading, ignoring error', () => {
|
|
11
|
+
const html = renderToString(<FetchResult loading={true} error={shaped('forbidden', 403)} />)
|
|
12
|
+
expect(html).toContain('Loading')
|
|
13
|
+
expect(html).not.toContain('Access denied')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders notFoundMessage when neither loading nor error (disabled-query fallback)', () => {
|
|
17
|
+
const html = renderToString(<FetchResult loading={false} notFoundMessage="Pod not found" />)
|
|
18
|
+
expect(html).toContain('Pod not found')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('default notFoundMessage is "Resource not found"', () => {
|
|
22
|
+
const html = renderToString(<FetchResult loading={false} />)
|
|
23
|
+
expect(html).toContain('Resource not found')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('shows "Access denied" headline and the server message on 403', () => {
|
|
27
|
+
const html = renderToString(
|
|
28
|
+
<FetchResult
|
|
29
|
+
loading={false}
|
|
30
|
+
error={shaped('no access to clusterroles (cluster-scoped resource requires explicit RBAC)', 403)}
|
|
31
|
+
/>,
|
|
32
|
+
)
|
|
33
|
+
expect(html).toContain('Access denied')
|
|
34
|
+
expect(html).toContain('no access to clusterroles')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('uses notFoundMessage and renders the server message on 404', () => {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
<FetchResult
|
|
40
|
+
loading={false}
|
|
41
|
+
error={shaped('pods web-1 not found', 404)}
|
|
42
|
+
notFoundMessage="Pod not found"
|
|
43
|
+
/>,
|
|
44
|
+
)
|
|
45
|
+
expect(html).toContain('Pod not found')
|
|
46
|
+
expect(html).toContain('pods web-1 not found')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders "Cluster unavailable" on 503', () => {
|
|
50
|
+
const html = renderToString(
|
|
51
|
+
<FetchResult loading={false} error={shaped('Resource cache not available', 503)} />,
|
|
52
|
+
)
|
|
53
|
+
expect(html).toContain('Cluster unavailable')
|
|
54
|
+
expect(html).toContain('Resource cache not available')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders "Sign-in required" on 401', () => {
|
|
58
|
+
const html = renderToString(
|
|
59
|
+
<FetchResult loading={false} error={shaped('Unauthorized', 401)} />,
|
|
60
|
+
)
|
|
61
|
+
expect(html).toContain('Sign-in required')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("renders a generic 'Couldn't load' for other 5xx", () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
<FetchResult loading={false} error={shaped('internal server error', 500)} />,
|
|
67
|
+
)
|
|
68
|
+
expect(html).toContain('Couldn')
|
|
69
|
+
expect(html).toContain('internal server error')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('handles a network failure (Error without .status) via the generic branch', () => {
|
|
73
|
+
const html = renderToString(
|
|
74
|
+
<FetchResult loading={false} error={new Error('Failed to fetch')} />,
|
|
75
|
+
)
|
|
76
|
+
expect(html).toContain('Couldn')
|
|
77
|
+
expect(html).toContain('Failed to fetch')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('handles an AbortError-style throw without a status field', () => {
|
|
81
|
+
const aborted = new Error('The user aborted a request.')
|
|
82
|
+
aborted.name = 'AbortError'
|
|
83
|
+
const html = renderToString(<FetchResult loading={false} error={aborted} />)
|
|
84
|
+
expect(html).toContain('Couldn')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('renders the Copy-error button when an error message is present', () => {
|
|
88
|
+
const html = renderToString(
|
|
89
|
+
<FetchResult loading={false} error={shaped('forbidden', 403)} />,
|
|
90
|
+
)
|
|
91
|
+
expect(html).toContain('Copy error')
|
|
92
|
+
})
|
|
93
|
+
})
|