@skyhook-io/radar-app 1.4.2 → 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.
Files changed (31) hide show
  1. package/package.json +10 -11
  2. package/src/App.tsx +168 -42
  3. package/src/RadarApp.tsx +9 -1
  4. package/src/api/client.ts +185 -6
  5. package/src/components/UserMenu.tsx +56 -10
  6. package/src/components/applications/ApplicationsView.tsx +27 -19
  7. package/src/components/audit/AuditSettingsDialog.tsx +1 -1
  8. package/src/components/audit/AuditView.tsx +23 -35
  9. package/src/components/gitops/GitOpsView.tsx +24 -2
  10. package/src/components/helm/HelmView.tsx +12 -8
  11. package/src/components/home/HomeView.tsx +1 -1
  12. package/src/components/home/mcpToolCatalog.ts +34 -0
  13. package/src/components/issues/IssuesPane.tsx +82 -28
  14. package/src/components/nav/PrimaryNavRail.tsx +282 -0
  15. package/src/components/resource/HPACharts.tsx +7 -2
  16. package/src/components/resource/RestartChart.tsx +8 -0
  17. package/src/components/resources/ResourcesView.tsx +54 -3
  18. package/src/components/resources/renderers/HPARenderer.tsx +4 -1
  19. package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
  20. package/src/components/settings/SettingsDialog.tsx +44 -7
  21. package/src/components/ui/CommandPalette.tsx +6 -215
  22. package/src/components/ui/Omnibar.tsx +493 -0
  23. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  24. package/src/components/ui/command-items.ts +178 -0
  25. package/src/components/workload/WorkloadView.tsx +3 -1
  26. package/src/context/NavCustomization.tsx +11 -0
  27. package/src/contexts/CapabilitiesContext.tsx +16 -5
  28. package/src/hooks/useMediaQuery.ts +21 -0
  29. package/src/hooks/useNavRailPinned.ts +46 -0
  30. package/src/hooks/useRecentResources.ts +49 -0
  31. package/src/utils/navigation.ts +11 -0
package/src/api/client.ts CHANGED
@@ -90,8 +90,8 @@ export function isForbiddenError(error: unknown): boolean {
90
90
  return error instanceof ApiError && error.status === 403
91
91
  }
92
92
 
93
- export async function fetchJSON<T>(path: string): Promise<T> {
94
- const response = await apiFetch(`${getApiBase()}${path}`)
93
+ export async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
94
+ const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
95
95
  if (!response.ok) {
96
96
  const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
97
97
  throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
@@ -691,6 +691,68 @@ export interface RuntimeStats {
691
691
  dynamicInformers?: number
692
692
  }
693
693
 
694
+ // ============================================================================
695
+ // Resource search (GET /api/search) — the existing search engine, RBAC-filtered
696
+ // and ranked server-side. Mirrors internal/search.Hit / .Result.
697
+ // ============================================================================
698
+
699
+ export interface SearchMatchedField {
700
+ token: string
701
+ /** "name" | "namespace" | "label:k" | "annotation:k" | "image" | "kind" | "content:path" */
702
+ site: string
703
+ score: number
704
+ }
705
+
706
+ export interface SearchSummaryContext {
707
+ health?: string
708
+ issueCount?: number
709
+ managedBy?: { kind?: string; name?: string } | null
710
+ }
711
+
712
+ export interface SearchHit {
713
+ score: number
714
+ kind: string
715
+ group?: string
716
+ namespace?: string
717
+ name: string
718
+ matched?: SearchMatchedField[]
719
+ summaryContext?: SearchSummaryContext
720
+ }
721
+
722
+ export interface SearchResult {
723
+ hits: SearchHit[]
724
+ total: number
725
+ searched: number
726
+ total_matched: number
727
+ }
728
+
729
+ const SEARCH_MIN_QUERY = 2
730
+
731
+ // useSearch hits the resource-search engine. The caller supplies the (already
732
+ // debounced) query; the hook is enabled only past the min length. include=none
733
+ // keeps the per-hit payload identity-only; context=summary attaches
734
+ // health/issueCount per hit (rich rows). React Query's AbortSignal cancels
735
+ // overlapping scans on a new query. keepPreviousData avoids flicker while the
736
+ // next query resolves.
737
+ export function useSearch(query: string, opts?: { limit?: number; context?: 'summary' | 'none'; enabled?: boolean; globalNs?: boolean }) {
738
+ const trimmed = query.trim()
739
+ const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
740
+ const limit = opts?.limit ?? 20
741
+ const context = opts?.context ?? 'summary'
742
+ // globalNs makes search ignore the per-user namespace-switcher pick and scan
743
+ // the user's full RBAC ceiling (scope then comes only from the query's `ns:`
744
+ // tokens). The omnibar opts in so ⌘K is a genuinely global lookup.
745
+ const globalNs = opts?.globalNs ?? false
746
+ return useQuery<SearchResult>({
747
+ queryKey: ['search', trimmed, limit, context, globalNs],
748
+ queryFn: ({ signal }) =>
749
+ fetchJSON<SearchResult>(`/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`, signal),
750
+ enabled,
751
+ staleTime: 2000,
752
+ placeholderData: (prev) => prev, // keepPreviousData
753
+ })
754
+ }
755
+
694
756
  export interface HealthResponse {
695
757
  status: string
696
758
  resourceCount: number
@@ -717,11 +779,10 @@ export function useCapabilities() {
717
779
  })
718
780
  }
719
781
 
720
- // Namespace-scoped capabilities: lazy re-check for exec/logs/portForward when
721
- // global RBAC checks denied them. Users with namespace-scoped RoleBindings may
782
+ // Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
722
783
  // have these permissions in specific namespaces.
723
- export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities) {
724
- const needsCheck = namespace && (!globalCaps.exec || !globalCaps.logs || !globalCaps.portForward)
784
+ export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities | undefined) {
785
+ const needsCheck = namespace && globalCaps
725
786
  return useQuery<Capabilities>({
726
787
  queryKey: ['capabilities', namespace],
727
788
  queryFn: () => fetchJSON(`/capabilities?namespace=${encodeURIComponent(namespace!)}`),
@@ -928,6 +989,7 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
928
989
  data: query.data?.resource,
929
990
  relationships: query.data?.relationships,
930
991
  certificateInfo: query.data?.certificateInfo,
992
+ hpaDiagnosis: query.data?.hpaDiagnosis,
931
993
  }
932
994
  }
933
995
 
@@ -1782,6 +1844,123 @@ export function useBulkDeleteResources() {
1782
1844
  })
1783
1845
  }
1784
1846
 
1847
+ interface BulkWorkloadItem {
1848
+ kind: string
1849
+ namespace: string
1850
+ name: string
1851
+ }
1852
+
1853
+ interface BulkWorkloadMutationResult {
1854
+ requested: number
1855
+ succeeded: number
1856
+ failedMessages: string[]
1857
+ }
1858
+
1859
+ function failedBulkWorkloadMessages(results: PromiseSettledResult<unknown>[]): string[] {
1860
+ return results.flatMap(r => r.status === 'rejected'
1861
+ ? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
1862
+ : []
1863
+ )
1864
+ }
1865
+
1866
+ function bulkWorkloadFailureMessage(action: string, failed: number, total: number, messages: string[]): string {
1867
+ return `Failed to ${action} ${failed} of ${total} workloads:\n${messages.join('\n')}`
1868
+ }
1869
+
1870
+ export function useBulkRestartWorkloads() {
1871
+ const queryClient = useQueryClient()
1872
+
1873
+ return useMutation({
1874
+ mutationFn: async ({ items }: { items: BulkWorkloadItem[] }): Promise<BulkWorkloadMutationResult> => {
1875
+ if (items.length === 0) {
1876
+ return { requested: 0, succeeded: 0, failedMessages: [] }
1877
+ }
1878
+ const results = await Promise.allSettled(
1879
+ items.map(async ({ kind, namespace, name }) => {
1880
+ const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
1881
+ method: 'POST',
1882
+ })
1883
+ if (!response.ok) {
1884
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1885
+ throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
1886
+ }
1887
+ return { kind, namespace, name }
1888
+ })
1889
+ )
1890
+ const failedMessages = failedBulkWorkloadMessages(results)
1891
+ if (failedMessages.length === items.length) {
1892
+ throw new Error(bulkWorkloadFailureMessage('restart', failedMessages.length, items.length, failedMessages))
1893
+ }
1894
+ return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
1895
+ },
1896
+ meta: {
1897
+ errorMessage: 'Failed to restart some workloads',
1898
+ },
1899
+ onSuccess: (result) => {
1900
+ if (result.failedMessages.length > 0) {
1901
+ showApiError(
1902
+ `Restarted ${result.succeeded} of ${result.requested} workloads`,
1903
+ result.failedMessages.join('\n'),
1904
+ )
1905
+ } else {
1906
+ showApiSuccess('Workloads restarting')
1907
+ }
1908
+ },
1909
+ onSettled: () => {
1910
+ queryClient.invalidateQueries({ queryKey: ['resources'] })
1911
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
1912
+ },
1913
+ })
1914
+ }
1915
+
1916
+ export function useBulkScaleWorkloads() {
1917
+ const queryClient = useQueryClient()
1918
+
1919
+ return useMutation({
1920
+ mutationFn: async ({ items, replicas }: { items: BulkWorkloadItem[]; replicas: number }): Promise<BulkWorkloadMutationResult> => {
1921
+ if (items.length === 0) {
1922
+ return { requested: 0, succeeded: 0, failedMessages: [] }
1923
+ }
1924
+ const results = await Promise.allSettled(
1925
+ items.map(async ({ kind, namespace, name }) => {
1926
+ const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, {
1927
+ method: 'POST',
1928
+ headers: { 'Content-Type': 'application/json' },
1929
+ body: JSON.stringify({ replicas }),
1930
+ })
1931
+ if (!response.ok) {
1932
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1933
+ throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
1934
+ }
1935
+ return { kind, namespace, name }
1936
+ })
1937
+ )
1938
+ const failedMessages = failedBulkWorkloadMessages(results)
1939
+ if (failedMessages.length === items.length) {
1940
+ throw new Error(bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages))
1941
+ }
1942
+ return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
1943
+ },
1944
+ meta: {
1945
+ errorMessage: 'Failed to scale some workloads',
1946
+ },
1947
+ onSuccess: (result) => {
1948
+ if (result.failedMessages.length > 0) {
1949
+ showApiError(
1950
+ `Scaled ${result.succeeded} of ${result.requested} workloads`,
1951
+ result.failedMessages.join('\n'),
1952
+ )
1953
+ } else {
1954
+ showApiSuccess('Workloads scaled')
1955
+ }
1956
+ },
1957
+ onSettled: () => {
1958
+ queryClient.invalidateQueries({ queryKey: ['resources'] })
1959
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
1960
+ },
1961
+ })
1962
+ }
1963
+
1785
1964
  // Apply (create or update) a resource from YAML
1786
1965
  export interface ApplyResourceResult {
1787
1966
  name: string
@@ -1,9 +1,19 @@
1
1
  import { useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { User, LogOut } from 'lucide-react'
3
+ import { clsx } from 'clsx'
3
4
  import { useAuthMe } from '../api/client'
4
5
  import { useQueryClient } from '@tanstack/react-query'
5
6
 
6
- export function UserMenu() {
7
+ interface UserMenuProps {
8
+ // 'topbar' (default): 27px avatar, dropdown opens downward.
9
+ // 'rail': a rail-bottom row (avatar + username, fly-out when slim), dropdown
10
+ // opens UPWARD + escapes the narrow column to the right.
11
+ variant?: 'topbar' | 'rail'
12
+ /** Rail variant only: expanded (labels) vs slim (icon + fly-out). */
13
+ pinned?: boolean
14
+ }
15
+
16
+ export function UserMenu({ variant = 'topbar', pinned = true }: UserMenuProps = {}) {
7
17
  const { data: authMe } = useAuthMe()
8
18
  const [isOpen, setIsOpen] = useState(false)
9
19
  const menuRef = useRef<HTMLDivElement>(null)
@@ -47,18 +57,54 @@ export function UserMenu() {
47
57
  .map(s => s[0]?.toUpperCase() || '')
48
58
  .join('')
49
59
 
60
+ const isRail = variant === 'rail'
61
+ const avatar = (
62
+ <span className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-500 flex items-center justify-center text-xs font-medium shrink-0">
63
+ {initials || <User className="w-3.5 h-3.5" />}
64
+ </span>
65
+ )
66
+
50
67
  return (
51
- <div ref={menuRef} className="relative">
52
- <button
53
- onClick={() => setIsOpen(!isOpen)}
54
- className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-500 flex items-center justify-center text-xs font-medium hover:bg-blue-500/25 transition-colors"
55
- title={authMe.username}
56
- >
57
- {initials || <User className="w-3.5 h-3.5" />}
58
- </button>
68
+ <div ref={menuRef} className={clsx('relative', isRail && 'group/item', isRail && !pinned && 'w-10')}>
69
+ {isRail ? (
70
+ <button
71
+ onClick={() => setIsOpen(!isOpen)}
72
+ title={authMe.username}
73
+ className={clsx(
74
+ 'relative flex h-9 w-full items-center rounded-md text-sm font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary transition-colors',
75
+ !pinned && 'max-w-10 overflow-hidden',
76
+ )}
77
+ >
78
+ <span className="flex w-10 shrink-0 items-center justify-center">{avatar}</span>
79
+ <span className={clsx('pr-3 truncate', !pinned && 'opacity-0')}>{authMe.username.split('@')[0]}</span>
80
+ </button>
81
+ ) : (
82
+ <button
83
+ onClick={() => setIsOpen(!isOpen)}
84
+ className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-500 flex items-center justify-center text-xs font-medium hover:bg-blue-500/25 transition-colors"
85
+ title={authMe.username}
86
+ >
87
+ {initials || <User className="w-3.5 h-3.5" />}
88
+ </button>
89
+ )}
90
+
91
+ {/* Slim-rail fly-out label (account row, collapsed) */}
92
+ {isRail && !pinned && !isOpen && (
93
+ <span
94
+ aria-hidden
95
+ className="pointer-events-none absolute left-full top-1/2 z-50 ml-1 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-theme-border bg-theme-hover px-2.5 py-1 text-[13px] font-medium text-theme-text-primary opacity-0 shadow-lg shadow-black/30 transition-opacity duration-75 group-hover/item:block group-hover/item:opacity-100"
96
+ >
97
+ Account
98
+ </span>
99
+ )}
59
100
 
60
101
  {isOpen && (
61
- <div className="absolute right-0 top-full mt-1.5 w-56 bg-theme-surface border border-theme-border rounded-lg shadow-lg z-50 py-1">
102
+ <div className={clsx(
103
+ 'absolute w-56 bg-theme-surface border border-theme-border rounded-lg shadow-lg z-50 py-1',
104
+ // Rail: open UP (it sits at the viewport bottom) and align to the rail's
105
+ // left edge so a 56px slim column doesn't clip it (it extends right).
106
+ isRail ? 'bottom-full left-2 mb-1.5' : 'right-0 top-full mt-1.5',
107
+ )}>
62
108
  <div className="px-3 py-2 border-b border-theme-border">
63
109
  <p className="text-sm font-medium text-theme-text-primary truncate">{authMe.username}</p>
64
110
  {authMe.groups && authMe.groups.length > 0 && (
@@ -4,6 +4,7 @@ import {
4
4
  ApplicationsList,
5
5
  ApplicationDetail,
6
6
  CenteredEmpty,
7
+ PageHeader,
7
8
  useToast,
8
9
  orderEnvs,
9
10
  matchWorkloadAcrossInstances,
@@ -65,26 +66,33 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
65
66
  return <AppDetailRoute app={selected} apps={apps} onBack={() => selectApp(null)} onOpenResource={onOpenResource} />
66
67
  }
67
68
 
68
- return (
69
- <div className="flex-1 overflow-auto px-4 py-4 sm:px-6">
70
- <header className="mb-4 flex flex-col gap-1">
71
- <h1 className="text-xl font-semibold text-theme-text-primary">Applications</h1>
72
- <p className="max-w-3xl text-sm text-theme-text-secondary">Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence.</p>
73
- </header>
69
+ // The header + status + filters + table chassis lives inside ApplicationsList
70
+ // (mirroring GitOpsTableView), which renders only on the data path. To keep
71
+ // the page header from vanishing while loading / on error, the wrapper shows
72
+ // the same header bar above those states. (Keep title + description in sync
73
+ // with ApplicationsList's PageHeader.)
74
+ if (query.isLoading || query.error) {
75
+ return (
76
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
77
+ <div className="shrink-0 border-b border-theme-border px-4 py-4">
78
+ <PageHeader
79
+ icon={Boxes}
80
+ title="Applications"
81
+ description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
82
+ />
83
+ </div>
84
+ {query.isLoading ? (
85
+ <CenteredEmpty icon={Boxes} headline="Loading applications…" />
86
+ ) : (
87
+ <CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
88
+ )}
89
+ </div>
90
+ )
91
+ }
74
92
 
75
- {query.isLoading ? (
76
- <CenteredEmpty icon={Boxes} headline="Loading applications…" />
77
- ) : query.error ? (
78
- <CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
79
- ) : apps.length === 0 ? (
80
- <CenteredEmpty
81
- icon={Boxes}
82
- headline="No applications detected yet"
83
- body="Deploy services, workers, or jobs to this cluster to see them grouped by app."
84
- />
85
- ) : (
86
- <ApplicationsList apps={apps} onSelect={selectApp} />
87
- )}
93
+ return (
94
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
95
+ <ApplicationsList apps={apps} onSelect={selectApp} />
88
96
  </div>
89
97
  )
90
98
  }
@@ -73,7 +73,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
73
73
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
74
74
  <div className="bg-theme-surface rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
75
75
  <div className="flex items-center justify-between px-5 py-4 border-b border-theme-border shrink-0">
76
- <h2 className="text-sm font-semibold text-theme-text-primary">Audit Settings</h2>
76
+ <h2 className="text-sm font-semibold text-theme-text-primary">Checks Settings</h2>
77
77
  <button onClick={onClose} className="p-1 rounded-lg hover:bg-theme-hover transition-colors">
78
78
  <X className="w-4 h-4 text-theme-text-tertiary" />
79
79
  </button>
@@ -1,13 +1,12 @@
1
1
  import { useState, useCallback } from 'react'
2
2
  import { useAudit, useAuditSettings, useUpdateAuditSettings, useCloudRole } from '../../api/client'
3
3
  import type { SelectedResource } from '../../types'
4
- import { ChecksView, PaneLoader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
5
- import { ArrowLeft, ClipboardCheck, Settings } from 'lucide-react'
4
+ import { ChecksView, PaneLoader, PageHeader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
5
+ import { ShieldCheck, Settings } from 'lucide-react'
6
6
  import { AuditSettingsDialog } from './AuditSettingsDialog'
7
7
 
8
8
  interface AuditViewProps {
9
9
  namespaces: string[]
10
- onBack: () => void
11
10
  onNavigateToResource: (resource: SelectedResource) => void
12
11
  }
13
12
 
@@ -17,7 +16,7 @@ interface AuditViewProps {
17
16
  // come pre-computed from radar's /api/audit (pkg/audit.BuildChecks); local
18
17
  // ~/.radar settings are this cluster's "policy" and the row hide-menu writes to
19
18
  // them.
20
- export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditViewProps) {
19
+ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps) {
21
20
  const { data, isLoading, error } = useAudit(namespaces)
22
21
  const { data: auditSettings } = useAuditSettings()
23
22
  const updateSettings = useUpdateAuditSettings()
@@ -73,37 +72,26 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
73
72
  onNavigateToResource({ kind: ref.kind, namespace: ref.namespace, name: ref.name, group: ref.group })
74
73
 
75
74
  return (
76
- <div className="flex-1 flex flex-col min-h-0 p-6 gap-6 overflow-auto">
77
- {/* Header */}
78
- <div className="flex items-center gap-4">
79
- <button
80
- onClick={onBack}
81
- className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors"
82
- >
83
- <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
84
- </button>
85
- <div className="flex-1">
86
- <div className="flex items-center gap-2">
87
- <ClipboardCheck className="w-5 h-5 text-theme-text-secondary" />
88
- <h1 className="text-lg font-semibold text-theme-text-primary">Checks</h1>
89
- </div>
90
- <p className="text-sm text-theme-text-tertiary mt-1 ml-7">
91
- Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue.
92
- </p>
93
- </div>
94
- <div className="flex items-center gap-2 shrink-0">
95
- {ignoredCount > 0 && (
96
- <button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
97
- )}
98
- <button
99
- onClick={() => setShowSettings(true)}
100
- className="p-2 rounded-lg hover:bg-theme-hover text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
101
- title="Checks settings"
102
- >
103
- <Settings className="w-4 h-4" />
104
- </button>
105
- </div>
106
- </div>
75
+ <div className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
76
+ <PageHeader
77
+ icon={ShieldCheck}
78
+ title="Checks"
79
+ description="Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue."
80
+ actions={
81
+ <>
82
+ {ignoredCount > 0 && (
83
+ <button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
84
+ )}
85
+ <button
86
+ onClick={() => setShowSettings(true)}
87
+ className="p-2 rounded-lg hover:bg-theme-hover text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
88
+ title="Checks settings"
89
+ >
90
+ <Settings className="w-4 h-4" />
91
+ </button>
92
+ </>
93
+ }
94
+ />
107
95
 
108
96
  <ChecksView
109
97
  checks={data.groupedChecks ?? []}
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from 'react'
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import yaml from 'yaml'
@@ -204,6 +204,28 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
204
204
  window.setTimeout(refetchTable, 1200)
205
205
  }
206
206
 
207
+ // Cold-cache catch-up: right after a cluster/namespace switch (or first open)
208
+ // the GitOps CRD informers can still be warming, so the first list resolves
209
+ // EMPTY even though apps exist — stranding the user on "No applications found"
210
+ // until the 120s poll (which is why a manual Refresh "fixes" it). When the
211
+ // fetch settles empty, briefly retry (bounded) to catch the cache as it syncs.
212
+ // Reset the budget whenever the cluster (apiResources identity) or namespace
213
+ // scope changes so each switch gets a fresh set of retries.
214
+ const coldRetriesRef = useRef(0)
215
+ // While we're still retrying a cold cache, the view shows a spinner (not the
216
+ // false "No applications found") — so the user sees "loading", not "empty".
217
+ const [coldRetrying, setColdRetrying] = useState(false)
218
+ useEffect(() => { coldRetriesRef.current = 0; setColdRetrying(false) }, [apiResources, namespacesParam])
219
+ useEffect(() => {
220
+ if (apiResourcesLoading || rowsQuery.isFetching) return
221
+ if ((rowsQuery.data?.length ?? 0) > 0) { setColdRetrying(false); return }
222
+ if (coldRetriesRef.current >= 4) { setColdRetrying(false); return }
223
+ setColdRetrying(true)
224
+ const t = window.setTimeout(() => { coldRetriesRef.current += 1; refetchTable() }, 2000)
225
+ return () => window.clearTimeout(t)
226
+ // eslint-disable-next-line react-hooks/exhaustive-deps
227
+ }, [rowsQuery.data, rowsQuery.isFetching, apiResourcesLoading])
228
+
207
229
  const handleRowAction = (row: GitOpsRow, action: GitOpsRowAction) => {
208
230
  const { kindName: kind, namespace, name, id } = row
209
231
  const settle = { onSuccess: refetchTableAfterMutation, onSettled: () => markAction(id, action, false) }
@@ -246,7 +268,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
246
268
  <>
247
269
  <SharedGitOpsTableView
248
270
  rows={rowsQuery.data ?? []}
249
- loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading}
271
+ loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading || coldRetrying}
250
272
  error={(rowsQuery.error as Error | null) ?? null}
251
273
  counts={countsQuery.data?.counts ?? {}}
252
274
  onRefresh={() => rowsQuery.refetch()}
@@ -2,7 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'r
2
2
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
3
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
4
4
  import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight } from 'lucide-react'
5
- import { PaneLoader } from '@skyhook-io/k8s-ui'
5
+ import { PaneLoader, PageHeader } from '@skyhook-io/k8s-ui'
6
6
  import { clsx } from 'clsx'
7
7
  import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
8
8
  import type { HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
@@ -167,6 +167,14 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
167
167
  <div className="flex h-full w-full">
168
168
  {/* Main Content */}
169
169
  <div className="flex-1 flex flex-col overflow-hidden min-w-0 w-full">
170
+ {/* Page header — consistent across views; the tabs + search sit below. */}
171
+ <div className="px-4 pt-4 pb-1">
172
+ <PageHeader
173
+ icon={Package}
174
+ title="Helm"
175
+ description="Installed Helm releases and the chart catalog for this cluster."
176
+ />
177
+ </div>
170
178
  {/* Tab bar */}
171
179
  <div className="flex items-center gap-1 px-4 pt-3 border-b border-theme-border bg-theme-surface/50">
172
180
  <button
@@ -204,13 +212,9 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
204
212
  <>
205
213
  {/* Releases Toolbar */}
206
214
  <div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 shrink-0">
207
- <div className="flex items-center gap-2 text-theme-text-secondary">
208
- <Package className="w-5 h-5" />
209
- <span className="font-medium">Helm Releases</span>
210
- {!isFullyLoaded && (
211
- <RefreshCw className="w-3.5 h-3.5 animate-spin text-theme-text-tertiary" />
212
- )}
213
- </div>
215
+ {!isFullyLoaded && (
216
+ <RefreshCw className="w-3.5 h-3.5 animate-spin text-theme-text-tertiary shrink-0" />
217
+ )}
214
218
  <div className="flex-1 relative">
215
219
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
216
220
  <input
@@ -182,7 +182,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
182
182
  <BandItem>
183
183
  <AuditCard
184
184
  data={data.audit}
185
- onNavigate={() => onNavigateToView('audit')}
185
+ onNavigate={() => onNavigateToView('checks')}
186
186
  />
187
187
  </BandItem>
188
188
  )}
@@ -201,6 +201,40 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
201
201
  { arg: 'namespace', desc: 'required for ServiceAccount; omit for User/Group' },
202
202
  ],
203
203
  },
204
+ {
205
+ name: 'query_prometheus',
206
+ desc: "Run PromQL against the cluster's Prometheus (auto-discovered or configured; works with Thanos, VictoriaMetrics, Mimir). Instant queries return current values; range queries return time-series history with automatic step adjustment. Oversized results return a cardinality summary with a suggested topk rewrite instead of raw data.",
207
+ params: [
208
+ { arg: 'query', required: true, desc: 'PromQL query to execute' },
209
+ { arg: 'type', desc: 'instant (default) or range' },
210
+ { arg: 'since', desc: 'range lookback, e.g. 30m, 1h, 24h, 7d (default 1h)' },
211
+ { arg: 'start', desc: 'range RFC3339 start time; overrides since' },
212
+ { arg: 'end', desc: 'range RFC3339 end time (default now)' },
213
+ { arg: 'step', desc: 'range resolution, e.g. 30s, 5m (auto-calculated when omitted)' },
214
+ { arg: 'max_points', desc: 'max data points per series (default 300, max 600)' },
215
+ { arg: 'timeout', desc: 'query timeout in seconds (default 30, max 180)' },
216
+ ],
217
+ },
218
+ {
219
+ name: 'discover_metrics',
220
+ desc: 'Discover exact Prometheus metric names (with type and help text) or values of one label before writing PromQL. Lists active series from the last hour; flags truncation so the selector can be narrowed.',
221
+ params: [
222
+ { arg: 'match', desc: 'PromQL series selector filter, e.g. {__name__=~"node_cpu.*"}; required when label is empty' },
223
+ { arg: 'label', desc: 'discover values of this label instead of metric names, e.g. namespace, pod' },
224
+ { arg: 'limit', desc: 'max values returned (default 100, max 500)' },
225
+ ],
226
+ },
227
+ {
228
+ name: 'get_prometheus_rules',
229
+ desc: 'List Prometheus alerting and recording rules with their PromQL definitions, state (firing/pending/inactive), and active alert instances. The starting point for alert investigation: fetch the rule, then query its expression.',
230
+ params: [
231
+ { arg: 'type', desc: 'alert or record (omit for both)' },
232
+ { arg: 'name', desc: 'substring filter on rule name' },
233
+ { arg: 'group', desc: 'substring filter on rule group name' },
234
+ { arg: 'state', desc: 'alerting rules only: firing, pending, or inactive' },
235
+ { arg: 'limit', desc: 'max rules returned (default 50, max 200)' },
236
+ ],
237
+ },
204
238
  {
205
239
  name: 'get_workload_logs',
206
240
  desc: 'Aggregated, filtered logs across all pods of a workload (Deployment, StatefulSet, or DaemonSet) — collected concurrently, filtered for errors/warnings, and deduplicated.',