@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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, type LucideIcon } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { PaneLoader } from './PaneLoader'
|
|
5
|
+
import { isFetchError } from '../../types/fetch-error'
|
|
6
|
+
|
|
7
|
+
// FetchResult collapses (loading, error, no-data) into one rendered outcome:
|
|
8
|
+
// loader while loading, a typed error surface when the fetch threw, or the
|
|
9
|
+
// notFoundMessage when neither — matching the prior plain-text "Resource not
|
|
10
|
+
// found" so a disabled React Query (loading=false, data=undefined, error=null
|
|
11
|
+
// under v5) still renders a sane fallback rather than going blank.
|
|
12
|
+
//
|
|
13
|
+
// Decision tree:
|
|
14
|
+
// loading → <PaneLoader/>
|
|
15
|
+
// error (403) → "Access denied" + error.message
|
|
16
|
+
// error (404) → notFoundMessage + error.message
|
|
17
|
+
// error (503) → "Cluster unavailable" + error.message
|
|
18
|
+
// error (401) → "Sign-in required" (apiFetch redirects; fallback)
|
|
19
|
+
// error (other / no shape) → "Couldn't load this view" + error.message
|
|
20
|
+
// no loading, no error → notFoundMessage (headline only, no detail)
|
|
21
|
+
//
|
|
22
|
+
// Separate from EmptyState (which conveys "no data here" with
|
|
23
|
+
// healthy/filtered/neutral tones) because HTTP-level fetch failures need
|
|
24
|
+
// distinct visual and informational semantics — error vs absence.
|
|
25
|
+
//
|
|
26
|
+
// The error contract is duck-typed via FetchErrorShape so this stays in
|
|
27
|
+
// @skyhook-io/k8s-ui without importing ApiError from either web/ (OSS)
|
|
28
|
+
// or radar-hub-web.
|
|
29
|
+
|
|
30
|
+
interface FetchResultProps {
|
|
31
|
+
loading: boolean
|
|
32
|
+
error?: unknown
|
|
33
|
+
/** Body line for 404 (resource fetched, server says missing). Default "Resource not found". */
|
|
34
|
+
notFoundMessage?: string
|
|
35
|
+
/** Pin to parent height. Existing call sites use "h-32" or "h-full". */
|
|
36
|
+
className?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function FetchResult({
|
|
40
|
+
loading,
|
|
41
|
+
error,
|
|
42
|
+
notFoundMessage = 'Resource not found',
|
|
43
|
+
className = 'h-32',
|
|
44
|
+
}: FetchResultProps) {
|
|
45
|
+
if (loading) {
|
|
46
|
+
return <PaneLoader className={className} />
|
|
47
|
+
}
|
|
48
|
+
if (error === undefined || error === null) {
|
|
49
|
+
// No loading + no error = the query is disabled or returned no data.
|
|
50
|
+
// Render the headline-only "not found" state so callers gated on `!data`
|
|
51
|
+
// don't end up with a blank body when React Query v5 leaves isLoading=false.
|
|
52
|
+
return (
|
|
53
|
+
<div className={clsx('flex items-center justify-center text-theme-text-tertiary', className)}>
|
|
54
|
+
{notFoundMessage}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return <ErrorSurface error={error} notFoundMessage={notFoundMessage} className={className} />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ErrorSurfaceProps {
|
|
62
|
+
error: unknown
|
|
63
|
+
notFoundMessage: string
|
|
64
|
+
className: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ErrorSurface({ error, notFoundMessage, className }: ErrorSurfaceProps) {
|
|
68
|
+
const classified = classify(error, notFoundMessage)
|
|
69
|
+
const Icon = classified.icon
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
role="status"
|
|
74
|
+
className={clsx('flex flex-col items-center justify-center gap-2 px-6 text-center', className)}
|
|
75
|
+
>
|
|
76
|
+
<Icon className="h-5 w-5 text-theme-text-tertiary" aria-hidden />
|
|
77
|
+
<div className="text-sm font-medium text-theme-text-secondary">{classified.headline}</div>
|
|
78
|
+
{classified.detail && (
|
|
79
|
+
<div className="flex items-center gap-2 max-w-md">
|
|
80
|
+
<span className="text-xs text-theme-text-tertiary break-words">{classified.detail}</span>
|
|
81
|
+
<CopyErrorButton text={classified.detail} />
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface Classified {
|
|
89
|
+
headline: string
|
|
90
|
+
detail: string | null
|
|
91
|
+
icon: LucideIcon
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function classify(error: unknown, notFoundMessage: string): Classified {
|
|
95
|
+
if (isFetchError(error)) {
|
|
96
|
+
switch (error.status) {
|
|
97
|
+
case 403:
|
|
98
|
+
return { headline: 'Access denied', detail: error.message, icon: ShieldOff }
|
|
99
|
+
case 404:
|
|
100
|
+
return { headline: notFoundMessage, detail: error.message, icon: AlertTriangle }
|
|
101
|
+
case 401:
|
|
102
|
+
return { headline: 'Sign-in required', detail: error.message, icon: LogIn }
|
|
103
|
+
case 503:
|
|
104
|
+
return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
|
|
105
|
+
default:
|
|
106
|
+
return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Network failures (no .status), DOMException for AbortError, anything thrown without our shape.
|
|
110
|
+
return { headline: "Couldn't load this view", detail: errorMessageOf(error), icon: AlertTriangle }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function errorMessageOf(error: unknown): string | null {
|
|
114
|
+
if (error instanceof Error && error.message) return error.message
|
|
115
|
+
if (typeof error === 'string') return error
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function CopyErrorButton({ text }: { text: string }) {
|
|
120
|
+
const [copied, setCopied] = useState(false)
|
|
121
|
+
const onCopy = () => {
|
|
122
|
+
navigator.clipboard.writeText(text).then(
|
|
123
|
+
() => {
|
|
124
|
+
setCopied(true)
|
|
125
|
+
window.setTimeout(() => setCopied(false), 1500)
|
|
126
|
+
},
|
|
127
|
+
() => { /* best-effort */ },
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return (
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={onCopy}
|
|
134
|
+
className="flex-shrink-0 p-1 rounded text-theme-text-tertiary hover:text-theme-text-secondary hover:bg-theme-hover"
|
|
135
|
+
title={copied ? 'Copied' : 'Copy error'}
|
|
136
|
+
aria-label={copied ? 'Copied' : 'Copy error'}
|
|
137
|
+
>
|
|
138
|
+
{copied ? <Check className="h-3 w-3" aria-hidden /> : <Copy className="h-3 w-3" aria-hidden />}
|
|
139
|
+
</button>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@ export { MiddleEllipsis } from './MiddleEllipsis'
|
|
|
5
5
|
export type { MiddleEllipsisProps } from './MiddleEllipsis'
|
|
6
6
|
export { EmptyState } from './EmptyState'
|
|
7
7
|
export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
|
|
8
|
+
export { FetchResult } from './FetchResult'
|
|
8
9
|
export { FilterPill } from './FilterPill'
|
|
9
10
|
export type { FilterPillTone } from './FilterPill'
|
|
10
11
|
export { StatusDot, mapHealthToTone } from './status-tone'
|
|
@@ -19,3 +20,5 @@ export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
|
|
|
19
20
|
export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
|
|
20
21
|
export { CodeViewer } from './CodeViewer'
|
|
21
22
|
export { YamlEditor, YamlDiffEditor } from './YamlEditor'
|
|
23
|
+
export { RowActionMenu } from './RowActionMenu'
|
|
24
|
+
export type { RowActionItem } from './RowActionMenu'
|
|
@@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } fro
|
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
3
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
4
|
import { startViewTransitionSafe } from '../../utils/view-transition'
|
|
5
|
-
import {
|
|
5
|
+
import { FetchResult } from '../ui/FetchResult'
|
|
6
6
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
7
7
|
import { clsx } from 'clsx'
|
|
8
8
|
import {
|
|
@@ -80,6 +80,9 @@ interface WorkloadViewProps {
|
|
|
80
80
|
certificateInfo?: any
|
|
81
81
|
/** Whether the resource is loading */
|
|
82
82
|
isLoading?: boolean
|
|
83
|
+
/** Fetch error for the resource (preserves status + message so the
|
|
84
|
+
* drawer body can distinguish 403/404/503 from "no data"). */
|
|
85
|
+
resourceError?: unknown
|
|
83
86
|
/** Function to refetch the resource data */
|
|
84
87
|
refetch?: () => void
|
|
85
88
|
|
|
@@ -187,6 +190,7 @@ export function WorkloadView({
|
|
|
187
190
|
relationships,
|
|
188
191
|
certificateInfo,
|
|
189
192
|
isLoading: resourceLoading = false,
|
|
193
|
+
resourceError,
|
|
190
194
|
refetch: refetchProp,
|
|
191
195
|
// Timeline
|
|
192
196
|
allEvents,
|
|
@@ -480,10 +484,8 @@ export function WorkloadView({
|
|
|
480
484
|
|
|
481
485
|
{/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
|
|
482
486
|
<div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
|
|
483
|
-
{
|
|
484
|
-
<
|
|
485
|
-
) : !resource ? (
|
|
486
|
-
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
|
|
487
|
+
{!resource ? (
|
|
488
|
+
<FetchResult loading={resourceLoading} error={resourceError} className="h-32" />
|
|
487
489
|
) : showYaml ? (
|
|
488
490
|
<EditableYamlView
|
|
489
491
|
resource={selectedResource}
|
|
@@ -660,6 +662,7 @@ export function WorkloadView({
|
|
|
660
662
|
selectedResource={selectedResource}
|
|
661
663
|
relationships={relationships}
|
|
662
664
|
isLoading={resourceLoading}
|
|
665
|
+
error={resourceError}
|
|
663
666
|
onNavigate={onNavigateToResource}
|
|
664
667
|
onCopy={copyToClipboard}
|
|
665
668
|
copied={copied}
|
|
@@ -711,10 +714,8 @@ export function WorkloadView({
|
|
|
711
714
|
)}
|
|
712
715
|
{activeTab === 'yaml' && (
|
|
713
716
|
<div className="h-full overflow-auto">
|
|
714
|
-
{
|
|
715
|
-
<
|
|
716
|
-
) : !resource ? (
|
|
717
|
-
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
|
|
717
|
+
{!resource ? (
|
|
718
|
+
<FetchResult loading={resourceLoading} error={resourceError} className="h-32" />
|
|
718
719
|
) : (
|
|
719
720
|
<EditableYamlView
|
|
720
721
|
resource={selectedResource}
|
|
@@ -1165,6 +1166,7 @@ function InfoTab({
|
|
|
1165
1166
|
selectedResource,
|
|
1166
1167
|
relationships,
|
|
1167
1168
|
isLoading,
|
|
1169
|
+
error,
|
|
1168
1170
|
onNavigate,
|
|
1169
1171
|
onCopy,
|
|
1170
1172
|
copied,
|
|
@@ -1185,6 +1187,7 @@ function InfoTab({
|
|
|
1185
1187
|
selectedResource: SelectedResource
|
|
1186
1188
|
relationships?: Relationships
|
|
1187
1189
|
isLoading: boolean
|
|
1190
|
+
error?: unknown
|
|
1188
1191
|
onNavigate?: NavigateToResource
|
|
1189
1192
|
onCopy: (text: string, key: string) => void
|
|
1190
1193
|
copied: string | null
|
|
@@ -1201,16 +1204,8 @@ function InfoTab({
|
|
|
1201
1204
|
updatesError?: Error | null
|
|
1202
1205
|
extraContent?: ReactNode
|
|
1203
1206
|
}) {
|
|
1204
|
-
if (isLoading) {
|
|
1205
|
-
return <PaneLoader className="h-full" />
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
1207
|
if (!resource) {
|
|
1209
|
-
return
|
|
1210
|
-
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
1211
|
-
Resource not found
|
|
1212
|
-
</div>
|
|
1213
|
-
)
|
|
1208
|
+
return <FetchResult loading={isLoading} error={error} className="h-full" />
|
|
1214
1209
|
}
|
|
1215
1210
|
|
|
1216
1211
|
return (
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isFetchError, isForbiddenError } from './fetch-error'
|
|
3
|
+
|
|
4
|
+
function shaped(message: string, status: number) {
|
|
5
|
+
return Object.assign(new Error(message), { status })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('isFetchError', () => {
|
|
9
|
+
it('accepts an Error decorated with a numeric status', () => {
|
|
10
|
+
expect(isFetchError(shaped('forbidden', 403))).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('accepts a plain object with status and message', () => {
|
|
14
|
+
expect(isFetchError({ status: 500, message: 'boom' })).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('rejects a network failure without a status field', () => {
|
|
18
|
+
expect(isFetchError(new Error('Failed to fetch'))).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('rejects abort/cancel DOMException-style throws (no status)', () => {
|
|
22
|
+
const aborted = new Error('The user aborted a request.')
|
|
23
|
+
aborted.name = 'AbortError'
|
|
24
|
+
expect(isFetchError(aborted)).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('rejects undefined, null, primitives', () => {
|
|
28
|
+
expect(isFetchError(undefined)).toBe(false)
|
|
29
|
+
expect(isFetchError(null)).toBe(false)
|
|
30
|
+
expect(isFetchError('forbidden')).toBe(false)
|
|
31
|
+
expect(isFetchError(403)).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('rejects an object with a non-numeric status', () => {
|
|
35
|
+
expect(isFetchError({ status: '403', message: 'forbidden' })).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('isForbiddenError', () => {
|
|
40
|
+
it('is true only for 403 on a fetch-error shape', () => {
|
|
41
|
+
expect(isForbiddenError(shaped('nope', 403))).toBe(true)
|
|
42
|
+
expect(isForbiddenError(shaped('nope', 404))).toBe(false)
|
|
43
|
+
expect(isForbiddenError(new Error('Failed to fetch'))).toBe(false)
|
|
44
|
+
expect(isForbiddenError(null)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// FetchErrorShape is the duck-typed contract every fetch error in the
|
|
2
|
+
// app already satisfies — both web/'s ApiError and radar-hub-web's
|
|
3
|
+
// ApiError expose .status and .message. Living in @skyhook-io/k8s-ui
|
|
4
|
+
// without importing either lets presentational components (FetchResult,
|
|
5
|
+
// ResourcesView's forbidden-kind sidebar) classify errors uniformly
|
|
6
|
+
// across the OSS binary and Radar Hub.
|
|
7
|
+
export interface FetchErrorShape {
|
|
8
|
+
status: number
|
|
9
|
+
message: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isFetchError(error: unknown): error is FetchErrorShape {
|
|
13
|
+
if (typeof error !== 'object' || error === null) return false
|
|
14
|
+
const e = error as Record<string, unknown>
|
|
15
|
+
return typeof e.status === 'number' && typeof e.message === 'string'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isForbiddenError(error: unknown): boolean {
|
|
19
|
+
return isFetchError(error) && error.status === 403
|
|
20
|
+
}
|