@skyhook-io/radar-app 1.5.0 → 1.6.0
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 +4 -4
- package/src/App.tsx +168 -42
- package/src/RadarApp.tsx +9 -1
- package/src/api/client.ts +65 -2
- package/src/components/UserMenu.tsx +56 -10
- package/src/components/applications/ApplicationsView.tsx +27 -19
- package/src/components/audit/AuditSettingsDialog.tsx +1 -1
- package/src/components/audit/AuditView.tsx +23 -35
- package/src/components/gitops/GitOpsView.tsx +24 -2
- package/src/components/helm/HelmView.tsx +12 -8
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +34 -0
- package/src/components/issues/IssuesPane.tsx +82 -28
- package/src/components/nav/PrimaryNavRail.tsx +282 -0
- package/src/components/resource/HPACharts.tsx +7 -2
- package/src/components/resource/RestartChart.tsx +8 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
- package/src/components/settings/SettingsDialog.tsx +18 -1
- package/src/components/ui/CommandPalette.tsx +6 -215
- package/src/components/ui/Omnibar.tsx +493 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +3 -1
- package/src/context/NavCustomization.tsx +11 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/utils/navigation.ts +11 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Home, Network, List, Clock, Package, Activity, Sun, Stethoscope, DollarSign, ShieldCheck, GitBranch, AlertTriangle, Boxes, Server } from 'lucide-react'
|
|
3
|
+
import { useNamespaces, useContexts } from '../../api/client'
|
|
4
|
+
import { CORE_RESOURCES, useAPIResources } from '../../api/apiResources'
|
|
5
|
+
import { getResourceIcon } from '../../utils/resource-icons'
|
|
6
|
+
import { parseContextName } from '../../utils/context-name'
|
|
7
|
+
|
|
8
|
+
// Drop the disambiguating " (source)" suffix the context list appends, so the
|
|
9
|
+
// GKE/EKS/AKS parser sees the bare context name (mirrors the cluster picker).
|
|
10
|
+
function stripSourceSuffix(name: string, source?: string): string {
|
|
11
|
+
if (!source) return name
|
|
12
|
+
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
13
|
+
return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'issues' | 'helm' | 'traffic' | 'cost' | 'checks' | 'gitops' | 'applications'
|
|
17
|
+
|
|
18
|
+
export interface CommandItem {
|
|
19
|
+
id: string
|
|
20
|
+
label: string
|
|
21
|
+
sublabel?: string
|
|
22
|
+
category: string
|
|
23
|
+
icon?: React.ComponentType<{ className?: string }>
|
|
24
|
+
shortcut?: string
|
|
25
|
+
action: () => void
|
|
26
|
+
/** Extra terms to match against during search (not displayed). */
|
|
27
|
+
searchTerms?: string[]
|
|
28
|
+
/** Small priority bonus added to the final score (only if the item matched). */
|
|
29
|
+
priorityBonus?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Built-in k8s API groups. Used to nudge these above CRDs on tied matches.
|
|
33
|
+
const CORE_GROUP_BONUS = 10
|
|
34
|
+
const WELL_KNOWN_GROUP_BONUS = 5
|
|
35
|
+
const WELL_KNOWN_GROUPS = new Set([
|
|
36
|
+
'apps', 'batch', 'autoscaling', 'policy', 'networking.k8s.io', 'rbac.authorization.k8s.io',
|
|
37
|
+
'storage.k8s.io', 'scheduling.k8s.io', 'coordination.k8s.io', 'apiextensions.k8s.io',
|
|
38
|
+
'admissionregistration.k8s.io', 'apiregistration.k8s.io', 'certificates.k8s.io',
|
|
39
|
+
'events.k8s.io', 'discovery.k8s.io', 'flowcontrol.apiserver.k8s.io', 'node.k8s.io',
|
|
40
|
+
'authentication.k8s.io', 'authorization.k8s.io',
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
function groupPriorityBonus(group: string): number {
|
|
44
|
+
if (!group) return CORE_GROUP_BONUS
|
|
45
|
+
if (WELL_KNOWN_GROUPS.has(group)) return WELL_KNOWN_GROUP_BONUS
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fuzzy match scoring: exact > prefix > word boundary > substring. Within a
|
|
50
|
+
// tier, a coverage bonus (up to +20) breaks ties in favor of shorter labels.
|
|
51
|
+
export function scoreMatch(text: string, query: string): number {
|
|
52
|
+
const lower = text.toLowerCase()
|
|
53
|
+
const q = query.toLowerCase()
|
|
54
|
+
if (!lower.includes(q)) return 0
|
|
55
|
+
let base: number
|
|
56
|
+
if (lower === q) base = 150
|
|
57
|
+
else if (lower.startsWith(q)) base = 100
|
|
58
|
+
else {
|
|
59
|
+
const wordStart = lower.indexOf(q)
|
|
60
|
+
const prev = lower[wordStart - 1]
|
|
61
|
+
base = wordStart > 0 && (prev === ' ' || prev === '/' || prev === '-' || prev === '.') ? 75 : 50
|
|
62
|
+
}
|
|
63
|
+
return base + (q.length / lower.length) * 20
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function bestScore(item: CommandItem, query: string): number {
|
|
67
|
+
let best = scoreMatch(item.label, query)
|
|
68
|
+
const secondary = Math.floor(Math.max(scoreMatch(item.sublabel || '', query), scoreMatch(item.category, query)) * 0.6)
|
|
69
|
+
best = Math.max(best, secondary)
|
|
70
|
+
if (item.searchTerms) {
|
|
71
|
+
for (const term of item.searchTerms) best = Math.max(best, scoreMatch(term, query))
|
|
72
|
+
}
|
|
73
|
+
return best > 0 ? best + (item.priorityBonus || 0) : 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CommandItemCallbacks {
|
|
77
|
+
onNavigateView: (view: MainView) => void
|
|
78
|
+
onNavigateKind: (kind: string, group: string) => void
|
|
79
|
+
onSwitchContext: (name: string) => void
|
|
80
|
+
onSetNamespaces: (ns: string[]) => void
|
|
81
|
+
onToggleTheme: () => void
|
|
82
|
+
onShowDiagnostics?: () => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const VIEW_ENTRIES: { view: MainView; label: string; icon: React.ComponentType<{ className?: string }>; shortcut: string }[] = [
|
|
86
|
+
{ view: 'home', label: 'Home', icon: Home, shortcut: 'g h' },
|
|
87
|
+
{ view: 'resources', label: 'Resources', icon: List, shortcut: 'g r' },
|
|
88
|
+
{ view: 'issues', label: 'Issues', icon: AlertTriangle, shortcut: 'g i' },
|
|
89
|
+
{ view: 'topology', label: 'Topology', icon: Network, shortcut: 'g t' },
|
|
90
|
+
{ view: 'applications', label: 'Applications', icon: Boxes, shortcut: 'g a' },
|
|
91
|
+
{ view: 'timeline', label: 'Timeline', icon: Clock, shortcut: 'g l' },
|
|
92
|
+
{ view: 'helm', label: 'Helm', icon: Package, shortcut: 'g m' },
|
|
93
|
+
{ view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: 'g o' },
|
|
94
|
+
{ view: 'traffic', label: 'Traffic', icon: Activity, shortcut: 'g f' },
|
|
95
|
+
{ view: 'checks', label: 'Checks', icon: ShieldCheck, shortcut: 'g u' },
|
|
96
|
+
{ view: 'cost', label: 'Cost', icon: DollarSign, shortcut: 'g c' },
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
// The static command-palette items (Views, Resource Kinds, Contexts,
|
|
100
|
+
// Namespaces, Actions) — shared by the centered modal (embedded) and the
|
|
101
|
+
// standalone omnibar so the two never drift.
|
|
102
|
+
export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
103
|
+
const { data: namespacesData } = useNamespaces()
|
|
104
|
+
const { data: contexts } = useContexts()
|
|
105
|
+
const { data: apiResources } = useAPIResources()
|
|
106
|
+
|
|
107
|
+
return useMemo<CommandItem[]>(() => {
|
|
108
|
+
const result: CommandItem[] = []
|
|
109
|
+
|
|
110
|
+
for (const v of VIEW_ENTRIES) {
|
|
111
|
+
result.push({ id: `view-${v.view}`, label: `Go to ${v.label}`, category: 'Views', icon: v.icon, shortcut: v.shortcut, action: () => cb.onNavigateView(v.view) })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const resources = apiResources || CORE_RESOURCES
|
|
115
|
+
const seenKinds = new Set<string>()
|
|
116
|
+
for (const r of resources) {
|
|
117
|
+
if (!r.verbs?.includes('list')) continue
|
|
118
|
+
const kindKey = `${r.name}/${r.group}`
|
|
119
|
+
if (seenKinds.has(kindKey)) continue
|
|
120
|
+
seenKinds.add(kindKey)
|
|
121
|
+
result.push({
|
|
122
|
+
// Group shown only when it disambiguates (CRDs) — "core" is noise on
|
|
123
|
+
// built-in kinds. priorityBonus still nudges core/well-known above CRDs.
|
|
124
|
+
id: `kind-${r.name}-${r.group}`, label: r.kind, sublabel: r.group || undefined, category: 'Resource Kinds',
|
|
125
|
+
icon: getResourceIcon(r.kind), action: () => cb.onNavigateKind(r.name, r.group),
|
|
126
|
+
searchTerms: [r.name, r.kind], priorityBonus: groupPriorityBonus(r.group),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (contexts) {
|
|
131
|
+
// Show the friendly parsed cluster name (like the cluster picker), not the
|
|
132
|
+
// raw ARN/gke context string. provider/region may live in the cluster id
|
|
133
|
+
// (e.g. EKS ARN) when the context name is already friendly — fall back to
|
|
134
|
+
// it. Count display names so genuine duplicates (same cluster name from
|
|
135
|
+
// different kubeconfig sources) stay distinguishable; unique ones stay clean.
|
|
136
|
+
const parsedCtx = contexts.map((ctx) => {
|
|
137
|
+
const parsed = parseContextName(stripSourceSuffix(ctx.name, ctx.source))
|
|
138
|
+
const fromCluster = ctx.cluster ? parseContextName(ctx.cluster) : null
|
|
139
|
+
const meta = [parsed.provider ?? fromCluster?.provider, parsed.region ?? fromCluster?.region].filter(Boolean).join(' · ')
|
|
140
|
+
return { ctx, clusterName: parsed.clusterName, account: parsed.account, base: ctx.isCurrent ? 'current' : meta }
|
|
141
|
+
})
|
|
142
|
+
// Disambiguate on the FINAL visible (label, sublabel) pair, not just the
|
|
143
|
+
// cluster name — same name + same provider/region from the same kubeconfig
|
|
144
|
+
// file would otherwise render identically while switching different
|
|
145
|
+
// contexts. Collisions fall back to the raw context name (unique by id).
|
|
146
|
+
const pairCount = new Map<string, number>()
|
|
147
|
+
for (const p of parsedCtx) pairCount.set(`${p.clusterName}\x00${p.base}`, (pairCount.get(`${p.clusterName}\x00${p.base}`) ?? 0) + 1)
|
|
148
|
+
for (const { ctx, clusterName, account, base } of parsedCtx) {
|
|
149
|
+
const collides = (pairCount.get(`${clusterName}\x00${base}`) ?? 0) > 1
|
|
150
|
+
const sub = [base, collides ? ctx.name : ''].filter(Boolean).join(' · ')
|
|
151
|
+
result.push({
|
|
152
|
+
id: `context-${ctx.name}`,
|
|
153
|
+
label: clusterName,
|
|
154
|
+
sublabel: sub || undefined,
|
|
155
|
+
category: 'Clusters',
|
|
156
|
+
icon: Server,
|
|
157
|
+
action: () => { if (!ctx.isCurrent) cb.onSwitchContext(ctx.name) },
|
|
158
|
+
searchTerms: [ctx.name, account || ''].filter(Boolean),
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (namespacesData) {
|
|
164
|
+
for (const ns of namespacesData) {
|
|
165
|
+
result.push({ id: `ns-${ns.name}`, label: ns.name, category: 'Namespaces', action: () => cb.onSetNamespaces([ns.name]) })
|
|
166
|
+
}
|
|
167
|
+
result.push({ id: 'ns-all', label: 'All Namespaces', category: 'Namespaces', action: () => cb.onSetNamespaces([]) })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
result.push({ id: 'action-theme', label: 'Toggle Theme', category: 'Actions', icon: Sun, shortcut: 't', action: () => cb.onToggleTheme() })
|
|
171
|
+
if (cb.onShowDiagnostics) {
|
|
172
|
+
result.push({ id: 'action-diagnostics', label: 'Diagnostics', category: 'Actions', icon: Stethoscope, action: () => cb.onShowDiagnostics?.(), searchTerms: ['debug', 'health', 'status', 'snapshot'] })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return result
|
|
176
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
177
|
+
}, [apiResources, contexts, namespacesData, cb.onNavigateView, cb.onNavigateKind, cb.onSwitchContext, cb.onSetNamespaces, cb.onToggleTheme, cb.onShowDiagnostics])
|
|
178
|
+
}
|
|
@@ -270,6 +270,7 @@ export function WorkloadView({
|
|
|
270
270
|
const resource = resourceResponse?.resource
|
|
271
271
|
const relationships = resourceResponse?.relationships
|
|
272
272
|
const certificateInfo = resourceResponse?.certificateInfo
|
|
273
|
+
const hpaDiagnosis = resourceResponse?.hpaDiagnosis
|
|
273
274
|
const relationshipGitopsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
|
|
274
275
|
const inheritedGitOpsLookupRef = useMemo(
|
|
275
276
|
() => findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, { kind: kindProp, namespace, name, group: rest.group }),
|
|
@@ -478,6 +479,7 @@ export function WorkloadView({
|
|
|
478
479
|
resource={resource}
|
|
479
480
|
relationships={relationships}
|
|
480
481
|
certificateInfo={certificateInfo}
|
|
482
|
+
hpaDiagnosis={hpaDiagnosis}
|
|
481
483
|
isLoading={resourceLoading}
|
|
482
484
|
resourceError={resourceError}
|
|
483
485
|
refetch={refetchResource}
|
|
@@ -811,7 +813,7 @@ function AuditSection({ kind, namespace, name }: { kind: string; namespace: stri
|
|
|
811
813
|
const navigate = useNavigate()
|
|
812
814
|
const { data: findings } = useResourceAudit(kind, namespace, name)
|
|
813
815
|
if (!findings || findings.length === 0) return null
|
|
814
|
-
return <AuditAlerts findings={findings} onViewAll={() => navigate('/
|
|
816
|
+
return <AuditAlerts findings={findings} onViewAll={() => navigate('/checks')} />
|
|
815
817
|
}
|
|
816
818
|
|
|
817
819
|
// FluxSourceConsumersSection lists the reconcilers (Kustomization, HelmRelease)
|
|
@@ -43,6 +43,17 @@ interface NavCustomizationBase {
|
|
|
43
43
|
* keeps its single-cluster Audit tab.
|
|
44
44
|
*/
|
|
45
45
|
clusterChecksHref?: () => string;
|
|
46
|
+
/**
|
|
47
|
+
* Chrome level for embedded hosts. Default ('full', or omitted) renders
|
|
48
|
+
* Radar's top bar + the view-switcher. 'none' suppresses BOTH — the host
|
|
49
|
+
* drives view navigation and cluster/namespace scope from its OWN chrome, and
|
|
50
|
+
* Radar renders just the active view's content full-bleed. Radar Hub uses this
|
|
51
|
+
* to surface per-cluster views (Topology / Resources / Traffic / Cost) that
|
|
52
|
+
* don't aggregate to the fleet as native cloud destinations under one chrome,
|
|
53
|
+
* gated by a cluster picker — instead of a second, redundant in-cluster nav.
|
|
54
|
+
* Only meaningful with `embedded: true`.
|
|
55
|
+
*/
|
|
56
|
+
chrome?: 'full' | 'none';
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
/**
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
// Subscribe to a CSS media query. SSR-safe (returns false before mount) and
|
|
4
|
+
// updates on viewport changes.
|
|
5
|
+
export function useMediaQuery(query: string): boolean {
|
|
6
|
+
const [matches, setMatches] = useState(() => {
|
|
7
|
+
if (typeof window === 'undefined' || !window.matchMedia) return false
|
|
8
|
+
return window.matchMedia(query).matches
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (typeof window === 'undefined' || !window.matchMedia) return
|
|
13
|
+
const mql = window.matchMedia(query)
|
|
14
|
+
const onChange = () => setMatches(mql.matches)
|
|
15
|
+
onChange()
|
|
16
|
+
mql.addEventListener('change', onChange)
|
|
17
|
+
return () => mql.removeEventListener('change', onChange)
|
|
18
|
+
}, [query])
|
|
19
|
+
|
|
20
|
+
return matches
|
|
21
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
// Pin state for the primary left nav rail.
|
|
4
|
+
//
|
|
5
|
+
// "Pinned" = the labeled, full-width sidebar; "unpinned" = the slim 56px
|
|
6
|
+
// icon rail with hover fly-out labels. This is a single committed choice
|
|
7
|
+
// the user makes once (default pinned), persisted to localStorage — NOT a
|
|
8
|
+
// per-route auto-collapse. A rail whose width changes as you navigate is
|
|
9
|
+
// disorienting; the rail is the stable anchor. A user who lives in the
|
|
10
|
+
// wide table views (Resources, GitOps) unpins once and keeps their width;
|
|
11
|
+
// fly-out labels + the ⌘K palette + `g`-mnemonic shortcuts cover the collapsed
|
|
12
|
+
// state's discoverability.
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = 'radar.navRail.pinned'
|
|
15
|
+
|
|
16
|
+
function readInitial(): boolean {
|
|
17
|
+
if (typeof window === 'undefined') return true
|
|
18
|
+
// `localStorage` access can throw (SecurityError) when storage is denied —
|
|
19
|
+
// sandboxed embeds, some privacy modes. Default to pinned (expanded), the
|
|
20
|
+
// friendlier first-run state for a non-k8s-expert; power users unpin once.
|
|
21
|
+
try {
|
|
22
|
+
return window.localStorage.getItem(STORAGE_KEY) !== 'false'
|
|
23
|
+
} catch {
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useNavRailPinned(): {
|
|
29
|
+
pinned: boolean
|
|
30
|
+
togglePinned: () => void
|
|
31
|
+
} {
|
|
32
|
+
const [pinned, setPinned] = useState(readInitial)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
try {
|
|
36
|
+
window.localStorage.setItem(STORAGE_KEY, String(pinned))
|
|
37
|
+
} catch {
|
|
38
|
+
// Private-mode / quota failures shouldn't break nav — the in-memory
|
|
39
|
+
// state still drives this session; we just don't persist it.
|
|
40
|
+
}
|
|
41
|
+
}, [pinned])
|
|
42
|
+
|
|
43
|
+
const togglePinned = useCallback(() => setPinned((p) => !p), [])
|
|
44
|
+
|
|
45
|
+
return { pinned, togglePinned }
|
|
46
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Recently-opened resources, persisted to localStorage so the omnibar's empty
|
|
2
|
+
// state can offer "jump back to what I was just looking at" across reloads.
|
|
3
|
+
// Plain functions (not a stateful hook): the omnibar reads fresh each time it
|
|
4
|
+
// opens and writes on open, so two component instances never need to sync.
|
|
5
|
+
|
|
6
|
+
export interface RecentResource {
|
|
7
|
+
kind: string
|
|
8
|
+
group?: string
|
|
9
|
+
namespace?: string
|
|
10
|
+
name: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const KEY = 'radar-recent-resources'
|
|
14
|
+
const MAX = 7
|
|
15
|
+
|
|
16
|
+
// Partition by the current cluster/context: a resource is identified by
|
|
17
|
+
// (kind, ns, name) WITHIN a cluster, so a global store would surface the prior
|
|
18
|
+
// cluster's names after a context switch and open ns/name in the wrong cluster.
|
|
19
|
+
// An UNKNOWN context (key still loading) is a hard no-op — no shared fallback
|
|
20
|
+
// bucket — so a recent is never read or written against an indeterminate cluster.
|
|
21
|
+
function storageKey(contextKey: string): string {
|
|
22
|
+
return `${KEY}::${contextKey}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function keyOf(r: RecentResource): string {
|
|
26
|
+
return `${r.kind}\x00${r.group || ''}\x00${r.namespace || ''}\x00${r.name}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadRecentResources(contextKey: string): RecentResource[] {
|
|
30
|
+
if (!contextKey) return []
|
|
31
|
+
try {
|
|
32
|
+
const raw = localStorage.getItem(storageKey(contextKey))
|
|
33
|
+
if (raw) return JSON.parse(raw)
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore parse/storage errors — recents are best-effort
|
|
36
|
+
}
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function recordRecentResource(r: RecentResource, contextKey: string): void {
|
|
41
|
+
if (!r.name || !r.kind || !contextKey) return
|
|
42
|
+
try {
|
|
43
|
+
const k = keyOf(r)
|
|
44
|
+
const next = [r, ...loadRecentResources(contextKey).filter((x) => keyOf(x) !== k)].slice(0, MAX)
|
|
45
|
+
localStorage.setItem(storageKey(contextKey), JSON.stringify(next))
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore storage errors
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/utils/navigation.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
|
|
2
2
|
import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
3
3
|
import type { SelectedResource } from '@skyhook-io/k8s-ui/types/core'
|
|
4
|
+
import type { SearchHit } from '../api/client'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Map a resource-search Hit to a SelectedResource. Hit.kind is the singular
|
|
8
|
+
* Kind (e.g. "Deployment"); downstream openers pluralize. `group` is carried so
|
|
9
|
+
* CRD/core collisions disambiguate (Service vs Knative Service), and the
|
|
10
|
+
* namespace defaults to '' for cluster-scoped hits (Node/Namespace/PV).
|
|
11
|
+
*/
|
|
12
|
+
export function searchHitToSelectedResource(hit: SearchHit): SelectedResource {
|
|
13
|
+
return { kind: hit.kind, namespace: hit.namespace ?? '', name: hit.name, group: hit.group || undefined }
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
// Re-export shared navigation utilities from @skyhook-io/k8s-ui.
|
|
6
17
|
export { kindToPlural, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
|