@skyhook-io/radar-app 1.9.0 → 1.9.2
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 +7 -7
- package/src/App.tsx +69 -16
- package/src/api/apiResources.test.ts +11 -0
- package/src/api/apiResources.ts +51 -12
- package/src/api/client.capacity.test.ts +92 -0
- package/src/api/client.ts +2905 -2081
- package/src/api/config.test.ts +47 -0
- package/src/api/config.ts +15 -0
- package/src/api/diagnose.ts +15 -15
- package/src/components/ConnectionErrorView.test.tsx +88 -0
- package/src/components/ConnectionErrorView.tsx +128 -22
- package/src/components/capacity/CapacityActivity.tsx +787 -0
- package/src/components/capacity/CapacityDemand.tsx +961 -0
- package/src/components/capacity/CapacityOverview.tsx +1529 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
- package/src/components/capacity/CapacityView.test.tsx +2287 -0
- package/src/components/capacity/CapacityView.tsx +85 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
- package/src/components/capacity/DemandNomination.test.tsx +151 -0
- package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
- package/src/components/capacity/coverageCertainty.test.ts +162 -0
- package/src/components/capacity/podDemandGate.test.ts +47 -0
- package/src/components/capacity/podDemandGate.ts +22 -0
- package/src/components/capacity/schedulingBar.test.ts +244 -0
- package/src/components/capacity/shared.tsx +1841 -0
- package/src/components/diagnose/AISettings.tsx +21 -7
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +127 -57
- package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/diagnose/parts.test.tsx +125 -0
- package/src/components/diagnose/parts.tsx +166 -75
- package/src/components/home/CapacityCard.test.tsx +150 -0
- package/src/components/home/CapacityCard.tsx +125 -0
- package/src/components/home/HomeView.tsx +15 -1
- package/src/components/issues/IssuesPane.test.ts +142 -0
- package/src/components/issues/IssuesPane.tsx +142 -38
- package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
- package/src/components/nav/PrimaryNavRail.tsx +191 -103
- package/src/components/resources/ResourcesView.tsx +9 -8
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/settings/SettingsDialog.tsx +31 -19
- package/src/components/timeline/TimelineView.tsx +17 -3
- package/src/components/ui/command-items.ts +222 -98
- package/src/components/workload/WorkloadView.tsx +16 -83
- package/src/context/ConnectionContext.test.ts +39 -0
- package/src/context/ConnectionContext.tsx +155 -51
- package/src/context/DiagnoseCustomization.tsx +1 -1
- package/src/utils/shell-safe.test.ts +55 -0
- package/src/utils/shell-safe.ts +21 -0
|
@@ -60,6 +60,7 @@ interface ConfigResponse {
|
|
|
60
60
|
interface SettingsDialogProps {
|
|
61
61
|
open: boolean
|
|
62
62
|
onClose: () => void
|
|
63
|
+
initialSection?: SettingsSectionId
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
// The settings surface splits into three honest apply buckets:
|
|
@@ -68,7 +69,7 @@ interface SettingsDialogProps {
|
|
|
68
69
|
// • Live integrations (Prometheus, Argo CD) — their own Apply/Connect endpoints
|
|
69
70
|
// re-point the running server; effect immediately, NOT part of footer dirty.
|
|
70
71
|
// • AI diagnose — client-side prefs, self-saving, editable by everyone.
|
|
71
|
-
type
|
|
72
|
+
export type SettingsSectionId =
|
|
72
73
|
| 'overview' | 'perms' | 'connection' | 'prometheus' | 'argocd' | 'ai' | 'advanced'
|
|
73
74
|
|
|
74
75
|
// Only STARTUP fields count toward footer dirty. Integration fields (prometheusUrl,
|
|
@@ -89,7 +90,11 @@ function normalizeStartup(c: Config) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
export function SettingsDialog({
|
|
93
|
+
export function SettingsDialog({
|
|
94
|
+
open,
|
|
95
|
+
onClose,
|
|
96
|
+
initialSection = 'overview',
|
|
97
|
+
}: SettingsDialogProps) {
|
|
93
98
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
94
99
|
const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
|
|
95
100
|
const { data: versionInfo } = useVersionCheck()
|
|
@@ -107,7 +112,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
107
112
|
const [saving, setSaving] = useState(false)
|
|
108
113
|
const [saveMessage, setSaveMessage] = useState<string | null>(null)
|
|
109
114
|
const [loadError, setLoadError] = useState<string | null>(null)
|
|
110
|
-
const [section, setSection] = useState<
|
|
115
|
+
const [section, setSection] = useState<SettingsSectionId>('overview')
|
|
111
116
|
const [confirmingClose, setConfirmingClose] = useState(false)
|
|
112
117
|
|
|
113
118
|
// AI Diagnosis prefs are client-side (localStorage) and now SELF-SAVING: the
|
|
@@ -117,14 +122,14 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
117
122
|
const aiAvailable = diag.available && diag.agents.length > 0
|
|
118
123
|
const [aiDraft, setAiDraft] = useState<AIDraft>({
|
|
119
124
|
agent: diag.selectedAgent,
|
|
120
|
-
|
|
125
|
+
profile: diag.profile,
|
|
121
126
|
model: diag.model,
|
|
122
127
|
effort: diag.effort,
|
|
123
128
|
})
|
|
124
129
|
const [aiSaved, setAiSaved] = useState(false)
|
|
125
130
|
const aiDirty =
|
|
126
131
|
aiDraft.agent !== diag.selectedAgent ||
|
|
127
|
-
aiDraft.
|
|
132
|
+
aiDraft.profile !== diag.profile ||
|
|
128
133
|
aiDraft.model !== diag.model ||
|
|
129
134
|
aiDraft.effort !== diag.effort
|
|
130
135
|
|
|
@@ -158,16 +163,10 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
158
163
|
setAiSaved(false)
|
|
159
164
|
setAiDraft({
|
|
160
165
|
agent: diag.selectedAgent,
|
|
161
|
-
|
|
166
|
+
profile: diag.profile,
|
|
162
167
|
model: diag.model,
|
|
163
168
|
effort: diag.effort,
|
|
164
169
|
})
|
|
165
|
-
// Overview is the landing section — a status-at-a-glance of what Radar is
|
|
166
|
-
// connected to (cluster, integrations, MCP, AI), useful to owners and
|
|
167
|
-
// viewers alike, rather than dropping owners on a config form or everyone
|
|
168
|
-
// on a permissions dump.
|
|
169
|
-
setSection('overview')
|
|
170
|
-
|
|
171
170
|
fetch(apiUrl('/config'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
|
|
172
171
|
.then((res) => {
|
|
173
172
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
@@ -185,6 +184,19 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
185
184
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
186
185
|
}, [open])
|
|
187
186
|
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (open) setSection(initialSection)
|
|
189
|
+
}, [open, initialSection])
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!open || diag.agents.length === 0) return
|
|
193
|
+
setAiDraft((current) => {
|
|
194
|
+
const profiles = diag.agents.find((agent) => agent.name === current.agent)?.profiles ?? []
|
|
195
|
+
if (profiles.length === 0 || profiles.includes(current.profile)) return current
|
|
196
|
+
return { ...current, profile: profiles[0] }
|
|
197
|
+
})
|
|
198
|
+
}, [open, diag.agents])
|
|
199
|
+
|
|
188
200
|
const updateConfigField = useCallback(<K extends keyof Config>(field: K, value: Config[K]) => {
|
|
189
201
|
setEditedConfig((prev) => ({ ...prev, [field]: value }))
|
|
190
202
|
setSaveMessage(null)
|
|
@@ -234,7 +246,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
234
246
|
// agent first, then restore the draft's model/effort.
|
|
235
247
|
const saveAi = useCallback(() => {
|
|
236
248
|
diag.setSelectedAgent(aiDraft.agent)
|
|
237
|
-
diag.
|
|
249
|
+
diag.setProfile(aiDraft.profile)
|
|
238
250
|
diag.setModel(aiDraft.model)
|
|
239
251
|
diag.setEffort(aiDraft.effort)
|
|
240
252
|
setAiSaved(true)
|
|
@@ -335,7 +347,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
335
347
|
// Fixed height so the dialog doesn't jump when switching tabs — short
|
|
336
348
|
// tabs leave breathing room, tall ones scroll inside the content pane.
|
|
337
349
|
// max-h keeps it on-screen on short viewports.
|
|
338
|
-
'sm:rounded-xl sm:max-w-4xl sm:mx-4 sm:h-[
|
|
350
|
+
'sm:rounded-xl sm:max-w-4xl sm:mx-4 sm:h-[660px] sm:max-h-[85vh]',
|
|
339
351
|
TRANSITION_PANEL,
|
|
340
352
|
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
|
341
353
|
)}
|
|
@@ -667,7 +679,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
667
679
|
// -- Sidebar primitives -------------------------------------------------------
|
|
668
680
|
|
|
669
681
|
interface NavItemDef {
|
|
670
|
-
id:
|
|
682
|
+
id: SettingsSectionId
|
|
671
683
|
label: string
|
|
672
684
|
icon: LucideIcon
|
|
673
685
|
ownerOnly: boolean
|
|
@@ -748,8 +760,8 @@ function SectionPane({
|
|
|
748
760
|
locked,
|
|
749
761
|
children,
|
|
750
762
|
}: {
|
|
751
|
-
id:
|
|
752
|
-
active:
|
|
763
|
+
id: SettingsSectionId
|
|
764
|
+
active: SettingsSectionId
|
|
753
765
|
title: string
|
|
754
766
|
caption?: string
|
|
755
767
|
live?: boolean
|
|
@@ -802,7 +814,7 @@ function LockWall() {
|
|
|
802
814
|
type OverviewTone = 'ok' | 'warn' | 'off' | 'unknown'
|
|
803
815
|
|
|
804
816
|
interface OverviewRow {
|
|
805
|
-
id:
|
|
817
|
+
id: SettingsSectionId
|
|
806
818
|
icon: LucideIcon
|
|
807
819
|
label: string
|
|
808
820
|
tone: OverviewTone
|
|
@@ -817,7 +829,7 @@ interface OverviewRow {
|
|
|
817
829
|
// probe, so we don't want it firing when Settings opens on another section);
|
|
818
830
|
// cluster and Prometheus status are shared app-wide caches, so they're read
|
|
819
831
|
// unconditionally.
|
|
820
|
-
function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s:
|
|
832
|
+
function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s: SettingsSectionId) => void }) {
|
|
821
833
|
const { data: cluster } = useClusterInfo()
|
|
822
834
|
const { data: prom } = usePrometheusStatus()
|
|
823
835
|
const { data: argo } = useArgoStatus(active)
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type TimelineGrouping,
|
|
18
18
|
type TimelineSort,
|
|
19
19
|
type PinnedLaneRef,
|
|
20
|
+
useDebouncedValue,
|
|
20
21
|
} from '@skyhook-io/k8s-ui'
|
|
21
22
|
import { TimelineList } from './TimelineList'
|
|
22
23
|
import type { ActivityFilterKey } from './TimelineList'
|
|
@@ -308,6 +309,17 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
308
309
|
// Search / activity-type / kind lifted here too, so they survive the view
|
|
309
310
|
// switch and drive both views through one source of truth.
|
|
310
311
|
const [search, setSearch] = useState(() => searchParams.get('q') ?? '')
|
|
312
|
+
// The `q` URL write is debounced so typing doesn't navigate per keystroke
|
|
313
|
+
// (clearing applies immediately — a delayed clear makes the × feel broken).
|
|
314
|
+
// The URL therefore lags the input while typing, so the URL→state read below
|
|
315
|
+
// must not sync a `q` that is merely the echo of our own write — it would
|
|
316
|
+
// revert in-flight keystrokes. Echoes are recognized by value: they carry
|
|
317
|
+
// exactly the debounced search we wrote. Ref instead of a dep: the read
|
|
318
|
+
// effect keys on searchParams alone (see its comment) and only needs the
|
|
319
|
+
// written value at fire time.
|
|
320
|
+
const debouncedSearch = useDebouncedValue(search, 300, (v) => v === '')
|
|
321
|
+
const writtenSearchRef = useRef(debouncedSearch)
|
|
322
|
+
writtenSearchRef.current = debouncedSearch
|
|
311
323
|
// Seed the multi-select from the URL `activity` csv, else the home-page
|
|
312
324
|
// deep-link preset: 'all'/undefined means no chips selected (everything).
|
|
313
325
|
const [activityFilter, setActivityFilter] = useState<ActivityFilterKey[]>(
|
|
@@ -621,7 +633,9 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
621
633
|
const nextPinnedOnly = sp.get('pinnedOnly') === '1' && pinnedLanes.length > 0
|
|
622
634
|
setPinnedOnly((prev) => (prev === nextPinnedOnly ? prev : nextPinnedOnly))
|
|
623
635
|
const nextSearch = sp.get('q') ?? ''
|
|
624
|
-
|
|
636
|
+
if (nextSearch !== writtenSearchRef.current) {
|
|
637
|
+
setSearch((prev) => (prev === nextSearch ? prev : nextSearch))
|
|
638
|
+
}
|
|
625
639
|
const nextActivity = parseActivity(sp) ?? []
|
|
626
640
|
setActivityFilter((prev) => (arraysEqual(prev, nextActivity) ? prev : nextActivity))
|
|
627
641
|
const nextKinds = parseKinds(sp)
|
|
@@ -638,7 +652,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
638
652
|
const current = searchParamsRef.current
|
|
639
653
|
const target = writeTimelineParams(
|
|
640
654
|
current,
|
|
641
|
-
{ viewMode, mode, showDeleted, pinnedOnly, search, activityFilter, kindFilter, grouping, sort, selectedEventId },
|
|
655
|
+
{ viewMode, mode, showDeleted, pinnedOnly, search: debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId },
|
|
642
656
|
{ isRetained: isRetained || isLocal, requiresNamespaceFilter: scopeRequiresNamespaceFilter },
|
|
643
657
|
)
|
|
644
658
|
const targetStr = target.toString()
|
|
@@ -652,7 +666,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
652
666
|
const replace = !didMountUrlSyncRef.current || onlyHighFreqDiffer(currentStr, targetStr)
|
|
653
667
|
didMountUrlSyncRef.current = true
|
|
654
668
|
setSearchParamsRef.current(target, { replace })
|
|
655
|
-
}, [viewMode, mode, showDeleted, pinnedOnly,
|
|
669
|
+
}, [viewMode, mode, showDeleted, pinnedOnly, debouncedSearch, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
|
|
656
670
|
|
|
657
671
|
// Fetch all activity - zoom controls what's visible in the UI. This ring feeds
|
|
658
672
|
// the swimlanes and the local strip's histogram, so it also runs in list mode
|
|
@@ -1,130 +1,200 @@
|
|
|
1
|
-
import { useMemo } from
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Home,
|
|
4
|
+
Network,
|
|
5
|
+
List,
|
|
6
|
+
Clock,
|
|
7
|
+
Package,
|
|
8
|
+
Activity,
|
|
9
|
+
Sun,
|
|
10
|
+
Stethoscope,
|
|
11
|
+
DollarSign,
|
|
12
|
+
Gauge,
|
|
13
|
+
ShieldCheck,
|
|
14
|
+
GitBranch,
|
|
15
|
+
AlertTriangle,
|
|
16
|
+
Boxes,
|
|
17
|
+
Server,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
import { useNamespaces, useContexts } from "../../api/client";
|
|
20
|
+
import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
|
|
21
|
+
import { getResourceIcon } from "../../utils/resource-icons";
|
|
22
|
+
import { parseContextName } from "../../utils/context-name";
|
|
7
23
|
|
|
8
24
|
// Drop the disambiguating " (source)" suffix the context list appends, so the
|
|
9
25
|
// GKE/EKS/AKS parser sees the bare context name (mirrors the cluster picker).
|
|
10
26
|
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+)?\\)$`),
|
|
27
|
+
if (!source) return name;
|
|
28
|
+
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
+
return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), "");
|
|
14
30
|
}
|
|
15
31
|
|
|
16
|
-
export type MainView =
|
|
32
|
+
export type MainView =
|
|
33
|
+
| "home"
|
|
34
|
+
| "topology"
|
|
35
|
+
| "resources"
|
|
36
|
+
| "timeline"
|
|
37
|
+
| "issues"
|
|
38
|
+
| "helm"
|
|
39
|
+
| "traffic"
|
|
40
|
+
| "cost"
|
|
41
|
+
| "capacity"
|
|
42
|
+
| "checks"
|
|
43
|
+
| "gitops"
|
|
44
|
+
| "applications";
|
|
17
45
|
|
|
18
46
|
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
|
|
47
|
+
id: string;
|
|
48
|
+
label: string;
|
|
49
|
+
sublabel?: string;
|
|
50
|
+
category: string;
|
|
51
|
+
icon?: React.ComponentType<{ className?: string }>;
|
|
52
|
+
shortcut?: string;
|
|
53
|
+
action: () => void;
|
|
26
54
|
/** Extra terms to match against during search (not displayed). */
|
|
27
|
-
searchTerms?: string[]
|
|
55
|
+
searchTerms?: string[];
|
|
28
56
|
/** Small priority bonus added to the final score (only if the item matched). */
|
|
29
|
-
priorityBonus?: number
|
|
57
|
+
priorityBonus?: number;
|
|
30
58
|
}
|
|
31
59
|
|
|
32
60
|
// 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
|
|
61
|
+
const CORE_GROUP_BONUS = 10;
|
|
62
|
+
const WELL_KNOWN_GROUP_BONUS = 5;
|
|
35
63
|
const WELL_KNOWN_GROUPS = new Set([
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
"apps",
|
|
65
|
+
"batch",
|
|
66
|
+
"autoscaling",
|
|
67
|
+
"policy",
|
|
68
|
+
"networking.k8s.io",
|
|
69
|
+
"rbac.authorization.k8s.io",
|
|
70
|
+
"storage.k8s.io",
|
|
71
|
+
"scheduling.k8s.io",
|
|
72
|
+
"coordination.k8s.io",
|
|
73
|
+
"apiextensions.k8s.io",
|
|
74
|
+
"admissionregistration.k8s.io",
|
|
75
|
+
"apiregistration.k8s.io",
|
|
76
|
+
"certificates.k8s.io",
|
|
77
|
+
"events.k8s.io",
|
|
78
|
+
"discovery.k8s.io",
|
|
79
|
+
"flowcontrol.apiserver.k8s.io",
|
|
80
|
+
"node.k8s.io",
|
|
81
|
+
"authentication.k8s.io",
|
|
82
|
+
"authorization.k8s.io",
|
|
83
|
+
]);
|
|
42
84
|
|
|
43
85
|
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
|
|
86
|
+
if (!group) return CORE_GROUP_BONUS;
|
|
87
|
+
if (WELL_KNOWN_GROUPS.has(group)) return WELL_KNOWN_GROUP_BONUS;
|
|
88
|
+
return 0;
|
|
47
89
|
}
|
|
48
90
|
|
|
49
91
|
// Fuzzy match scoring: exact > prefix > word boundary > substring. Within a
|
|
50
92
|
// tier, a coverage bonus (up to +20) breaks ties in favor of shorter labels.
|
|
51
93
|
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
|
|
94
|
+
const lower = text.toLowerCase();
|
|
95
|
+
const q = query.toLowerCase();
|
|
96
|
+
if (!lower.includes(q)) return 0;
|
|
97
|
+
let base: number;
|
|
98
|
+
if (lower === q) base = 150;
|
|
99
|
+
else if (lower.startsWith(q)) base = 100;
|
|
58
100
|
else {
|
|
59
|
-
const wordStart = lower.indexOf(q)
|
|
60
|
-
const prev = lower[wordStart - 1]
|
|
61
|
-
base =
|
|
101
|
+
const wordStart = lower.indexOf(q);
|
|
102
|
+
const prev = lower[wordStart - 1];
|
|
103
|
+
base =
|
|
104
|
+
wordStart > 0 &&
|
|
105
|
+
(prev === " " || prev === "/" || prev === "-" || prev === ".")
|
|
106
|
+
? 75
|
|
107
|
+
: 50;
|
|
62
108
|
}
|
|
63
|
-
return base + (q.length / lower.length) * 20
|
|
109
|
+
return base + (q.length / lower.length) * 20;
|
|
64
110
|
}
|
|
65
111
|
|
|
66
112
|
export function bestScore(item: CommandItem, query: string): number {
|
|
67
|
-
let best = scoreMatch(item.label, query)
|
|
68
|
-
const secondary = Math.floor(
|
|
69
|
-
|
|
113
|
+
let best = scoreMatch(item.label, query);
|
|
114
|
+
const secondary = Math.floor(
|
|
115
|
+
Math.max(
|
|
116
|
+
scoreMatch(item.sublabel || "", query),
|
|
117
|
+
scoreMatch(item.category, query),
|
|
118
|
+
) * 0.6,
|
|
119
|
+
);
|
|
120
|
+
best = Math.max(best, secondary);
|
|
70
121
|
if (item.searchTerms) {
|
|
71
|
-
for (const term of item.searchTerms)
|
|
122
|
+
for (const term of item.searchTerms)
|
|
123
|
+
best = Math.max(best, scoreMatch(term, query));
|
|
72
124
|
}
|
|
73
|
-
return best > 0 ? best + (item.priorityBonus || 0) : 0
|
|
125
|
+
return best > 0 ? best + (item.priorityBonus || 0) : 0;
|
|
74
126
|
}
|
|
75
127
|
|
|
76
128
|
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
|
|
129
|
+
onNavigateView: (view: MainView) => void;
|
|
130
|
+
onNavigateKind: (kind: string, group: string) => void;
|
|
131
|
+
onSwitchContext: (name: string) => void;
|
|
132
|
+
onSetNamespaces: (ns: string[]) => void;
|
|
133
|
+
onToggleTheme: () => void;
|
|
134
|
+
onShowDiagnostics?: () => void;
|
|
83
135
|
}
|
|
84
136
|
|
|
85
|
-
const VIEW_ENTRIES: {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{ view:
|
|
92
|
-
{ view:
|
|
93
|
-
{ view:
|
|
94
|
-
{ view:
|
|
95
|
-
{ view:
|
|
96
|
-
{ view:
|
|
97
|
-
|
|
137
|
+
const VIEW_ENTRIES: {
|
|
138
|
+
view: MainView;
|
|
139
|
+
label: string;
|
|
140
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
141
|
+
shortcut: string;
|
|
142
|
+
}[] = [
|
|
143
|
+
{ view: "home", label: "Home", icon: Home, shortcut: "g h" },
|
|
144
|
+
{ view: "resources", label: "Resources", icon: List, shortcut: "g r" },
|
|
145
|
+
{ view: "issues", label: "Issues", icon: AlertTriangle, shortcut: "g i" },
|
|
146
|
+
{ view: "topology", label: "Topology", icon: Network, shortcut: "g t" },
|
|
147
|
+
{ view: "applications", label: "Applications", icon: Boxes, shortcut: "g a" },
|
|
148
|
+
{ view: "timeline", label: "Timeline", icon: Clock, shortcut: "g l" },
|
|
149
|
+
{ view: "helm", label: "Helm", icon: Package, shortcut: "g m" },
|
|
150
|
+
{ view: "gitops", label: "GitOps", icon: GitBranch, shortcut: "g o" },
|
|
151
|
+
{ view: "traffic", label: "Live Traffic", icon: Activity, shortcut: "g f" },
|
|
152
|
+
{ view: "checks", label: "Checks", icon: ShieldCheck, shortcut: "g u" },
|
|
153
|
+
{ view: "capacity", label: "Capacity", icon: Gauge, shortcut: "g p" },
|
|
154
|
+
{ view: "cost", label: "Cost", icon: DollarSign, shortcut: "g c" },
|
|
155
|
+
];
|
|
98
156
|
|
|
99
157
|
// The static command-palette items (Views, Resource Kinds, Contexts,
|
|
100
158
|
// Namespaces, Actions) — shared by the centered modal (embedded) and the
|
|
101
159
|
// standalone omnibar so the two never drift.
|
|
102
160
|
export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
103
|
-
const { data: namespacesData } = useNamespaces()
|
|
104
|
-
const { data: contexts } = useContexts()
|
|
105
|
-
const { data: apiResources } = useAPIResources()
|
|
161
|
+
const { data: namespacesData } = useNamespaces();
|
|
162
|
+
const { data: contexts } = useContexts();
|
|
163
|
+
const { data: apiResources } = useAPIResources();
|
|
106
164
|
|
|
107
165
|
return useMemo<CommandItem[]>(() => {
|
|
108
|
-
const result: CommandItem[] = []
|
|
166
|
+
const result: CommandItem[] = [];
|
|
109
167
|
|
|
110
168
|
for (const v of VIEW_ENTRIES) {
|
|
111
|
-
result.push({
|
|
169
|
+
result.push({
|
|
170
|
+
id: `view-${v.view}`,
|
|
171
|
+
label: `Go to ${v.label}`,
|
|
172
|
+
category: "Views",
|
|
173
|
+
icon: v.icon,
|
|
174
|
+
shortcut: v.shortcut,
|
|
175
|
+
action: () => cb.onNavigateView(v.view),
|
|
176
|
+
});
|
|
112
177
|
}
|
|
113
178
|
|
|
114
|
-
const resources = apiResources || CORE_RESOURCES
|
|
115
|
-
const seenKinds = new Set<string>()
|
|
179
|
+
const resources = apiResources || CORE_RESOURCES;
|
|
180
|
+
const seenKinds = new Set<string>();
|
|
116
181
|
for (const r of resources) {
|
|
117
|
-
if (!r.verbs?.includes(
|
|
118
|
-
const kindKey = `${r.name}/${r.group}
|
|
119
|
-
if (seenKinds.has(kindKey)) continue
|
|
120
|
-
seenKinds.add(kindKey)
|
|
182
|
+
if (!r.verbs?.includes("list")) continue;
|
|
183
|
+
const kindKey = `${r.name}/${r.group}`;
|
|
184
|
+
if (seenKinds.has(kindKey)) continue;
|
|
185
|
+
seenKinds.add(kindKey);
|
|
121
186
|
result.push({
|
|
122
187
|
// Group shown only when it disambiguates (CRDs) — "core" is noise on
|
|
123
188
|
// built-in kinds. priorityBonus still nudges core/well-known above CRDs.
|
|
124
|
-
id: `kind-${r.name}-${r.group}`,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
189
|
+
id: `kind-${r.name}-${r.group}`,
|
|
190
|
+
label: r.kind,
|
|
191
|
+
sublabel: r.group || undefined,
|
|
192
|
+
category: "Resource Kinds",
|
|
193
|
+
icon: getResourceIcon(r.kind),
|
|
194
|
+
action: () => cb.onNavigateKind(r.name, r.group),
|
|
195
|
+
searchTerms: [r.name, r.kind],
|
|
196
|
+
priorityBonus: groupPriorityBonus(r.group),
|
|
197
|
+
});
|
|
128
198
|
}
|
|
129
199
|
|
|
130
200
|
if (contexts) {
|
|
@@ -134,45 +204,99 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
|
134
204
|
// it. Count display names so genuine duplicates (same cluster name from
|
|
135
205
|
// different kubeconfig sources) stay distinguishable; unique ones stay clean.
|
|
136
206
|
const parsedCtx = contexts.map((ctx) => {
|
|
137
|
-
const parsed = parseContextName(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
207
|
+
const parsed = parseContextName(
|
|
208
|
+
stripSourceSuffix(ctx.name, ctx.source),
|
|
209
|
+
);
|
|
210
|
+
const fromCluster = ctx.cluster ? parseContextName(ctx.cluster) : null;
|
|
211
|
+
const meta = [
|
|
212
|
+
parsed.provider ?? fromCluster?.provider,
|
|
213
|
+
parsed.region ?? fromCluster?.region,
|
|
214
|
+
]
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.join(" · ");
|
|
217
|
+
return {
|
|
218
|
+
ctx,
|
|
219
|
+
clusterName: parsed.clusterName,
|
|
220
|
+
account: parsed.account,
|
|
221
|
+
base: ctx.isCurrent ? "current" : meta,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
142
224
|
// Disambiguate on the FINAL visible (label, sublabel) pair, not just the
|
|
143
225
|
// cluster name — same name + same provider/region from the same kubeconfig
|
|
144
226
|
// file would otherwise render identically while switching different
|
|
145
227
|
// 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)
|
|
228
|
+
const pairCount = new Map<string, number>();
|
|
229
|
+
for (const p of parsedCtx)
|
|
230
|
+
pairCount.set(
|
|
231
|
+
`${p.clusterName}\x00${p.base}`,
|
|
232
|
+
(pairCount.get(`${p.clusterName}\x00${p.base}`) ?? 0) + 1,
|
|
233
|
+
);
|
|
148
234
|
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 :
|
|
235
|
+
const collides = (pairCount.get(`${clusterName}\x00${base}`) ?? 0) > 1;
|
|
236
|
+
const sub = [base, collides ? ctx.name : ""]
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
.join(" · ");
|
|
151
239
|
result.push({
|
|
152
240
|
id: `context-${ctx.name}`,
|
|
153
241
|
label: clusterName,
|
|
154
242
|
sublabel: sub || undefined,
|
|
155
|
-
category:
|
|
243
|
+
category: "Clusters",
|
|
156
244
|
icon: Server,
|
|
157
|
-
action: () => {
|
|
158
|
-
|
|
159
|
-
|
|
245
|
+
action: () => {
|
|
246
|
+
if (!ctx.isCurrent) cb.onSwitchContext(ctx.name);
|
|
247
|
+
},
|
|
248
|
+
searchTerms: [ctx.name, account || ""].filter(Boolean),
|
|
249
|
+
});
|
|
160
250
|
}
|
|
161
251
|
}
|
|
162
252
|
|
|
163
253
|
if (namespacesData) {
|
|
164
254
|
for (const ns of namespacesData) {
|
|
165
|
-
result.push({
|
|
255
|
+
result.push({
|
|
256
|
+
id: `ns-${ns.name}`,
|
|
257
|
+
label: ns.name,
|
|
258
|
+
category: "Namespaces",
|
|
259
|
+
action: () => cb.onSetNamespaces([ns.name]),
|
|
260
|
+
});
|
|
166
261
|
}
|
|
167
|
-
result.push({
|
|
262
|
+
result.push({
|
|
263
|
+
id: "ns-all",
|
|
264
|
+
label: "All Namespaces",
|
|
265
|
+
category: "Namespaces",
|
|
266
|
+
action: () => cb.onSetNamespaces([]),
|
|
267
|
+
});
|
|
168
268
|
}
|
|
169
269
|
|
|
170
|
-
result.push({
|
|
270
|
+
result.push({
|
|
271
|
+
id: "action-theme",
|
|
272
|
+
label: "Toggle Theme",
|
|
273
|
+
category: "Actions",
|
|
274
|
+
icon: Sun,
|
|
275
|
+
shortcut: "t",
|
|
276
|
+
action: () => cb.onToggleTheme(),
|
|
277
|
+
});
|
|
171
278
|
if (cb.onShowDiagnostics) {
|
|
172
|
-
result.push({
|
|
279
|
+
result.push({
|
|
280
|
+
id: "action-diagnostics",
|
|
281
|
+
label: "Diagnostics",
|
|
282
|
+
category: "Actions",
|
|
283
|
+
icon: Stethoscope,
|
|
284
|
+
action: () => cb.onShowDiagnostics?.(),
|
|
285
|
+
searchTerms: ["debug", "health", "status", "snapshot"],
|
|
286
|
+
});
|
|
173
287
|
}
|
|
174
288
|
|
|
175
|
-
return result
|
|
289
|
+
return result;
|
|
176
290
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
177
|
-
}, [
|
|
291
|
+
}, [
|
|
292
|
+
apiResources,
|
|
293
|
+
contexts,
|
|
294
|
+
namespacesData,
|
|
295
|
+
cb.onNavigateView,
|
|
296
|
+
cb.onNavigateKind,
|
|
297
|
+
cb.onSwitchContext,
|
|
298
|
+
cb.onSetNamespaces,
|
|
299
|
+
cb.onToggleTheme,
|
|
300
|
+
cb.onShowDiagnostics,
|
|
301
|
+
]);
|
|
178
302
|
}
|