@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.
Files changed (52) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +69 -16
  3. package/src/api/apiResources.test.ts +11 -0
  4. package/src/api/apiResources.ts +51 -12
  5. package/src/api/client.capacity.test.ts +92 -0
  6. package/src/api/client.ts +2905 -2081
  7. package/src/api/config.test.ts +47 -0
  8. package/src/api/config.ts +15 -0
  9. package/src/api/diagnose.ts +15 -15
  10. package/src/components/ConnectionErrorView.test.tsx +88 -0
  11. package/src/components/ConnectionErrorView.tsx +128 -22
  12. package/src/components/capacity/CapacityActivity.tsx +787 -0
  13. package/src/components/capacity/CapacityDemand.tsx +961 -0
  14. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  15. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  16. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  17. package/src/components/capacity/CapacityView.tsx +85 -0
  18. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  19. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  20. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  21. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  22. package/src/components/capacity/podDemandGate.test.ts +47 -0
  23. package/src/components/capacity/podDemandGate.ts +22 -0
  24. package/src/components/capacity/schedulingBar.test.ts +244 -0
  25. package/src/components/capacity/shared.tsx +1841 -0
  26. package/src/components/diagnose/AISettings.tsx +21 -7
  27. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  28. package/src/components/diagnose/DiagnoseContext.tsx +127 -57
  29. package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
  30. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  31. package/src/components/diagnose/agentCatalog.ts +30 -0
  32. package/src/components/diagnose/parts.test.tsx +125 -0
  33. package/src/components/diagnose/parts.tsx +166 -75
  34. package/src/components/home/CapacityCard.test.tsx +150 -0
  35. package/src/components/home/CapacityCard.tsx +125 -0
  36. package/src/components/home/HomeView.tsx +15 -1
  37. package/src/components/issues/IssuesPane.test.ts +142 -0
  38. package/src/components/issues/IssuesPane.tsx +142 -38
  39. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  40. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  41. package/src/components/resources/ResourcesView.tsx +9 -8
  42. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  43. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  44. package/src/components/settings/SettingsDialog.tsx +31 -19
  45. package/src/components/timeline/TimelineView.tsx +17 -3
  46. package/src/components/ui/command-items.ts +222 -98
  47. package/src/components/workload/WorkloadView.tsx +16 -83
  48. package/src/context/ConnectionContext.test.ts +39 -0
  49. package/src/context/ConnectionContext.tsx +155 -51
  50. package/src/context/DiagnoseCustomization.tsx +1 -1
  51. package/src/utils/shell-safe.test.ts +55 -0
  52. 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 SectionId =
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({ open, onClose }: SettingsDialogProps) {
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<SectionId>('overview')
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
- isolated: diag.isolated,
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.isolated !== diag.isolated ||
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
- isolated: diag.isolated,
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.setIsolated(aiDraft.isolated)
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-[620px] sm:max-h-[85vh]',
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: SectionId
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: SectionId
752
- active: SectionId
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: SectionId
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: SectionId) => void }) {
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
- setSearch((prev) => (prev === nextSearch ? prev : nextSearch))
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, search, activityFilter, kindFilter, grouping, sort, selectedEventId, isRetained, isLocal, scopeRequiresNamespaceFilter])
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 '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'
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 = 'home' | 'topology' | 'resources' | 'timeline' | 'issues' | 'helm' | 'traffic' | 'cost' | 'checks' | 'gitops' | 'applications'
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
- '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
- ])
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 = wordStart > 0 && (prev === ' ' || prev === '/' || prev === '-' || prev === '.') ? 75 : 50
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(Math.max(scoreMatch(item.sublabel || '', query), scoreMatch(item.category, query)) * 0.6)
69
- best = Math.max(best, secondary)
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) best = Math.max(best, scoreMatch(term, query))
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: { 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: 'Live 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
- ]
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({ id: `view-${v.view}`, label: `Go to ${v.label}`, category: 'Views', icon: v.icon, shortcut: v.shortcut, action: () => cb.onNavigateView(v.view) })
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('list')) continue
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}`, 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
- })
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(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
- })
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) pairCount.set(`${p.clusterName}\x00${p.base}`, (pairCount.get(`${p.clusterName}\x00${p.base}`) ?? 0) + 1)
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 : ''].filter(Boolean).join(' · ')
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: 'Clusters',
243
+ category: "Clusters",
156
244
  icon: Server,
157
- action: () => { if (!ctx.isCurrent) cb.onSwitchContext(ctx.name) },
158
- searchTerms: [ctx.name, account || ''].filter(Boolean),
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({ id: `ns-${ns.name}`, label: ns.name, category: 'Namespaces', action: () => cb.onSetNamespaces([ns.name]) })
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({ id: 'ns-all', label: 'All Namespaces', category: 'Namespaces', action: () => cb.onSetNamespaces([]) })
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({ id: 'action-theme', label: 'Toggle Theme', category: 'Actions', icon: Sun, shortcut: 't', action: () => cb.onToggleTheme() })
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({ id: 'action-diagnostics', label: 'Diagnostics', category: 'Actions', icon: Stethoscope, action: () => cb.onShowDiagnostics?.(), searchTerms: ['debug', 'health', 'status', 'snapshot'] })
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
- }, [apiResources, contexts, namespacesData, cb.onNavigateView, cb.onNavigateKind, cb.onSwitchContext, cb.onSetNamespaces, cb.onToggleTheme, cb.onShowDiagnostics])
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
  }