@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.
- package/package.json +10 -11
- package/src/App.tsx +168 -42
- package/src/RadarApp.tsx +9 -1
- package/src/api/client.ts +185 -6
- 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/ResourcesView.tsx +54 -3
- package/src/components/resources/renderers/HPARenderer.tsx +4 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
- package/src/components/settings/SettingsDialog.tsx +44 -7
- 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/contexts/CapabilitiesContext.tsx +16 -5
- 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
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
|
|
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 &&
|
|
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
|
-
|
|
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=
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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=
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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">
|
|
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 {
|
|
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,
|
|
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-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
208
|
-
<
|
|
209
|
-
|
|
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('
|
|
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.',
|