@skyhook-io/radar-app 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/App.tsx +178 -44
- package/src/RadarApp.tsx +9 -1
- package/src/api/client.ts +75 -5
- 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/ChartBrowser.tsx +1 -1
- package/src/components/helm/HelmView.tsx +12 -8
- package/src/components/helm/RoleGatedPanel.tsx +1 -1
- 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/ResourceDetailDrawer.tsx +3 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +34 -3
- package/src/components/settings/SettingsDialog.tsx +18 -1
- package/src/components/timeline/TimelineList.tsx +6 -1
- package/src/components/timeline/TimelineView.tsx +9 -0
- package/src/components/ui/CommandPalette.tsx +6 -215
- package/src/components/ui/Omnibar.tsx +603 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +3 -1
- package/src/context/NavCustomization.tsx +11 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.ts +15 -0
- package/src/utils/navigation.ts +11 -0
|
@@ -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()}
|
|
@@ -27,7 +27,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
27
27
|
|
|
28
28
|
// Repo refresh is gated only by `requireHelmWrite` on the backend
|
|
29
29
|
// (handleUpdateRepository deliberately skips requireCloudRole — it
|
|
30
|
-
// mutates pod-local chart cache, not cluster state). So the
|
|
30
|
+
// mutates pod-local chart cache, not cluster state). So the frontend gate
|
|
31
31
|
// here must NOT include the Cloud role check, or Cloud viewers with
|
|
32
32
|
// rbac.helm=true would be blocked from a refresh the backend allows.
|
|
33
33
|
const canHelmWrite = useCanHelmWrite()
|
|
@@ -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
|
|
@@ -22,7 +22,7 @@ interface RoleGatedPanelProps {
|
|
|
22
22
|
*
|
|
23
23
|
* Bypasses for non-Cloud users (OSS, OIDC, etc.) — `canAtLeast` returns
|
|
24
24
|
* true when no Cloud role is present. The backend gate has the same
|
|
25
|
-
* shape, so the
|
|
25
|
+
* shape, so the frontend stays in lockstep.
|
|
26
26
|
*/
|
|
27
27
|
export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps) {
|
|
28
28
|
const { role, canAtLeast } = useCloudRole()
|
|
@@ -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.',
|
|
@@ -1,11 +1,23 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
1
2
|
import { useIssues } from '../../api/client'
|
|
2
3
|
import type { SelectedResource } from '../../types'
|
|
3
|
-
import {
|
|
4
|
-
|
|
4
|
+
import {
|
|
5
|
+
IssuesView,
|
|
6
|
+
PaneLoader,
|
|
7
|
+
PageHeader,
|
|
8
|
+
SummaryTile,
|
|
9
|
+
ISSUE_SEVERITIES,
|
|
10
|
+
ISSUE_SEVERITY_LABEL,
|
|
11
|
+
type IssueResourceRef,
|
|
12
|
+
type IssueSeverity,
|
|
13
|
+
type SummaryTone,
|
|
14
|
+
} from '@skyhook-io/k8s-ui'
|
|
15
|
+
import { AlertTriangle } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = { critical: 'error', warning: 'warning' }
|
|
5
18
|
|
|
6
19
|
interface IssuesPaneProps {
|
|
7
20
|
namespaces: string[]
|
|
8
|
-
onBack: () => void
|
|
9
21
|
onNavigateToResource: (resource: SelectedResource) => void
|
|
10
22
|
}
|
|
11
23
|
|
|
@@ -13,9 +25,28 @@ interface IssuesPaneProps {
|
|
|
13
25
|
// (IssuesView) the Hub fleet view uses — single cluster here, so no cluster
|
|
14
26
|
// label and in-app (client-side) resource navigation. Classification +
|
|
15
27
|
// owner-grouping come pre-computed from radar's /api/issues
|
|
16
|
-
// (internal/issues.Compose → Classify → Group).
|
|
17
|
-
|
|
28
|
+
// (internal/issues.Compose → Classify → Group). Filtering is the host's job
|
|
29
|
+
// (IssuesView is a pure list); single-cluster gets a light severity filter via
|
|
30
|
+
// the header status tiles (clickable → filter), matching the Applications /
|
|
31
|
+
// GitOps header-tile pattern rather than Hub's fleet facet sidebar.
|
|
32
|
+
export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps) {
|
|
18
33
|
const { data, isLoading, error } = useIssues(namespaces)
|
|
34
|
+
const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
|
|
35
|
+
|
|
36
|
+
const allIssues = data?.issues ?? []
|
|
37
|
+
const totals = useMemo(() => {
|
|
38
|
+
const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 }
|
|
39
|
+
for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1
|
|
40
|
+
return t
|
|
41
|
+
}, [allIssues])
|
|
42
|
+
const shown = severityFilter.size ? allIssues.filter((i) => severityFilter.has(i.severity)) : allIssues
|
|
43
|
+
|
|
44
|
+
const toggleSeverity = (s: IssueSeverity) =>
|
|
45
|
+
setSeverityFilter((prev) => {
|
|
46
|
+
const next = new Set(prev)
|
|
47
|
+
next.has(s) ? next.delete(s) : next.add(s)
|
|
48
|
+
return next
|
|
49
|
+
})
|
|
19
50
|
|
|
20
51
|
const onResourceClick = (ref: IssueResourceRef) =>
|
|
21
52
|
onNavigateToResource({ kind: ref.kind, namespace: ref.namespace ?? '', name: ref.name, group: ref.group ?? '' })
|
|
@@ -33,30 +64,37 @@ export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesP
|
|
|
33
64
|
}
|
|
34
65
|
|
|
35
66
|
return (
|
|
36
|
-
<div className="flex-1 flex flex-col min-h-0 p-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
<div className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
|
|
68
|
+
<PageHeader
|
|
69
|
+
icon={AlertTriangle}
|
|
70
|
+
title="Issues"
|
|
71
|
+
description="Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect."
|
|
72
|
+
actions={
|
|
73
|
+
allIssues.length > 0 ? (
|
|
74
|
+
<>
|
|
75
|
+
<SummaryTile label={allIssues.length === 1 ? 'issue' : 'issues'} value={allIssues.length} />
|
|
76
|
+
{ISSUE_SEVERITIES.map((s) =>
|
|
77
|
+
totals[s] > 0 || severityFilter.has(s) ? (
|
|
78
|
+
<SummaryTile
|
|
79
|
+
key={s}
|
|
80
|
+
label={ISSUE_SEVERITY_LABEL[s]}
|
|
81
|
+
value={totals[s]}
|
|
82
|
+
tone={SEVERITY_TONE[s]}
|
|
83
|
+
active={severityFilter.has(s)}
|
|
84
|
+
onClick={() => toggleSeverity(s)}
|
|
85
|
+
/>
|
|
86
|
+
) : null,
|
|
87
|
+
)}
|
|
88
|
+
</>
|
|
89
|
+
) : undefined
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
54
92
|
|
|
55
93
|
{/* Visibility honesty: when RBAC reads are incomplete, an empty queue may
|
|
56
94
|
mean "can't see" rather than "nothing broken" — say so up front so the
|
|
57
95
|
empty state isn't mistaken for a clean bill of health. */}
|
|
58
96
|
{data?.visibility?.impact && (
|
|
59
|
-
<div className="
|
|
97
|
+
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
|
|
60
98
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
|
61
99
|
<span>Limited visibility — {data.visibility.impact} Results may be incomplete.</span>
|
|
62
100
|
</div>
|
|
@@ -65,14 +103,30 @@ export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesP
|
|
|
65
103
|
{/* Truncation honesty: when more issues matched than were returned, say
|
|
66
104
|
so — don't present a capped list as the complete picture. */}
|
|
67
105
|
{data?.total_matched != null && data.total_matched > (data.issues?.length ?? 0) && (
|
|
68
|
-
<p className="
|
|
106
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
69
107
|
Showing {data.issues?.length ?? 0} of {data.total_matched} issues (capped) — narrow by namespace to see the rest.
|
|
70
108
|
</p>
|
|
71
109
|
)}
|
|
72
110
|
|
|
73
|
-
{/*
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
{/* Filtered-empty is NOT the healthy empty state: when a severity filter
|
|
112
|
+
hides every row but issues still exist, say "no matches" rather than
|
|
113
|
+
letting IssuesView render its "nothing broken" terminal state. */}
|
|
114
|
+
{severityFilter.size > 0 && allIssues.length > 0 && shown.length === 0 ? (
|
|
115
|
+
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-theme-text-secondary">
|
|
116
|
+
<p>No issues match the selected severity.</p>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => setSeverityFilter(new Set())}
|
|
120
|
+
className="text-xs text-skyhook-600 hover:text-skyhook-500 dark:text-skyhook-400"
|
|
121
|
+
>
|
|
122
|
+
Clear filter
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
/* anyData = the query resolved, i.e. the cluster is reachable; an empty
|
|
127
|
+
list then means "nothing broken" rather than "not connected". */
|
|
128
|
+
<IssuesView issues={shown} anyData={!!data} onResourceClick={onResourceClick} />
|
|
129
|
+
)}
|
|
76
130
|
</div>
|
|
77
131
|
)
|
|
78
132
|
}
|