@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.
@@ -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 { PaneLoader } from '../ui/PaneLoader'
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
- {resourceLoading ? (
484
- <PaneLoader className="h-32" />
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
- {resourceLoading ? (
715
- <PaneLoader className="h-32" />
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
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './core'
2
+ export * from './fetch-error'
2
3
  export * from './gitops'
3
4
  export * from './gitops-tree'
4
5
  export * from './gitops-insights'