@skyhook-io/radar-app 1.3.4 → 1.4.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 +1 -1
- package/src/App.tsx +21 -3
- package/src/api/client.ts +67 -7
- package/src/components/ConnectionErrorView.tsx +11 -2
- package/src/components/UserMenu.tsx +10 -11
- package/src/components/home/mcpToolCatalog.ts +25 -8
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -3
- package/src/components/resources/ResourcesView.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +28 -1
- package/src/components/workload/WorkloadView.tsx +16 -1
- package/src/context/ConnectionContext.tsx +8 -8
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
|
|
6
6
|
import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
|
|
7
7
|
import { HomeView } from './components/home/HomeView'
|
|
8
8
|
import { DebugOverlay } from './components/DebugOverlay'
|
|
9
|
-
import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
|
|
9
|
+
import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind, gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
|
|
10
10
|
import { TimelineView } from './components/timeline/TimelineView'
|
|
11
11
|
import { ResourcesView } from './components/resources/ResourcesView'
|
|
12
12
|
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
@@ -416,6 +416,23 @@ function AppInner() {
|
|
|
416
416
|
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
417
417
|
}, [searchParams, navigate])
|
|
418
418
|
|
|
419
|
+
// From the Issues queue: a GitOps reconciler subject (Argo Application / Flux
|
|
420
|
+
// Kustomization / HelmRelease) routes to its rich detail page (tree + insights
|
|
421
|
+
// + ops), not the generic resource drawer that's a dead-end for it. Member
|
|
422
|
+
// resources (Pods, Services, …) fall through to the standard resource view.
|
|
423
|
+
const navigateFromIssue = useCallback((resource: SelectedResource) => {
|
|
424
|
+
const gitOpsPath = gitOpsRouteForResource({
|
|
425
|
+
apiVersion: resource.group ? `${resource.group}/v1` : 'v1',
|
|
426
|
+
kind: resource.kind,
|
|
427
|
+
metadata: { namespace: resource.namespace ?? '', name: resource.name },
|
|
428
|
+
})
|
|
429
|
+
if (gitOpsPath) {
|
|
430
|
+
navigate(gitOpsPath)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
navigateToResourceList(resource)
|
|
434
|
+
}, [navigate, navigateToResourceList])
|
|
435
|
+
|
|
419
436
|
// Collapse from expanded WorkloadView back to drawer
|
|
420
437
|
const handleCollapseFromExpanded = useCallback(() => {
|
|
421
438
|
suppressViewClearRef.current = true
|
|
@@ -1667,12 +1684,13 @@ function AppInner() {
|
|
|
1667
1684
|
|
|
1668
1685
|
{/* Issues — per-cluster live triage queue (hidden route: not yet in the
|
|
1669
1686
|
nav `views` list; reachable at /issues). Same shared <IssuesView> the
|
|
1670
|
-
Hub fleet uses;
|
|
1687
|
+
Hub fleet uses; a GitOps reconciler subject routes to its detail page,
|
|
1688
|
+
other resources open the standard resource view. */}
|
|
1671
1689
|
{mainView === 'issues' && (
|
|
1672
1690
|
<IssuesPane
|
|
1673
1691
|
namespaces={namespaces}
|
|
1674
1692
|
onBack={() => setMainView('home')}
|
|
1675
|
-
onNavigateToResource={
|
|
1693
|
+
onNavigateToResource={navigateFromIssue}
|
|
1676
1694
|
/>
|
|
1677
1695
|
)}
|
|
1678
1696
|
|
package/src/api/client.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { pluralToKind } from '../utils/navigation'
|
|
|
35
35
|
// and handles 401 responses globally. Merges caller-provided headers with
|
|
36
36
|
// auth headers from the config module so library consumers (Radar Hub) can
|
|
37
37
|
// inject Authorization bearer tokens without each call site knowing.
|
|
38
|
-
function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
38
|
+
export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
39
39
|
const headers = new Headers(init?.headers)
|
|
40
40
|
for (const [k, v] of Object.entries(getAuthHeaders())) {
|
|
41
41
|
if (!headers.has(k)) headers.set(k, v)
|
|
@@ -237,7 +237,7 @@ export interface DashboardCRDCount {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
// Re-export shared types from k8s-ui — single source of truth
|
|
240
|
-
import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check, Issue } from '@skyhook-io/k8s-ui'
|
|
240
|
+
import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check, Issue, IssueRecentChange } from '@skyhook-io/k8s-ui'
|
|
241
241
|
export type DashboardAudit = AuditCardData
|
|
242
242
|
export type { AuditFinding, ResourceGroup, CheckMeta, Check }
|
|
243
243
|
|
|
@@ -349,6 +349,8 @@ export interface IssuesResponse {
|
|
|
349
349
|
issues: Issue[]
|
|
350
350
|
total?: number
|
|
351
351
|
total_matched?: number
|
|
352
|
+
recent_changes?: IssueRecentChange[]
|
|
353
|
+
recent_changes_reason?: string
|
|
352
354
|
// Present only when RBAC visibility is incomplete (absent = full access).
|
|
353
355
|
// state 'degraded' means core workload reads are denied, so an empty list may
|
|
354
356
|
// mean "can't see" rather than "nothing broken" — the UI must say so.
|
|
@@ -738,6 +740,10 @@ export interface AuthMe {
|
|
|
738
740
|
/** Pre-computed Cloud tier from `cloud:<tier>` group prefix.
|
|
739
741
|
* Absent when not running under Cloud (OSS, OIDC, no role group). */
|
|
740
742
|
cloudRole?: CloudRole
|
|
743
|
+
/** Proxy mode only: whether an upstream sign-out URL is configured.
|
|
744
|
+
* When false, logout clears Radar's cookie but the proxy may re-auth
|
|
745
|
+
* the same user on the next request. */
|
|
746
|
+
proxyLogoutConfigured?: boolean
|
|
741
747
|
}
|
|
742
748
|
|
|
743
749
|
export function useAuthMe() {
|
|
@@ -926,7 +932,12 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
|
|
|
926
932
|
}
|
|
927
933
|
|
|
928
934
|
// List resources - queryKey includes group for cache sharing with ResourcesView
|
|
929
|
-
export function useResources<T>(
|
|
935
|
+
export function useResources<T>(
|
|
936
|
+
kind: string,
|
|
937
|
+
namespace?: string,
|
|
938
|
+
group?: string,
|
|
939
|
+
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
940
|
+
) {
|
|
930
941
|
const params = new URLSearchParams()
|
|
931
942
|
if (namespace) params.set('namespace', namespace)
|
|
932
943
|
if (group) params.set('group', group)
|
|
@@ -937,6 +948,7 @@ export function useResources<T>(kind: string, namespace?: string, group?: string
|
|
|
937
948
|
queryFn: () => fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ''}`),
|
|
938
949
|
enabled: (options?.enabled ?? true) && Boolean(kind),
|
|
939
950
|
staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
|
|
951
|
+
refetchInterval: options?.refetchInterval,
|
|
940
952
|
})
|
|
941
953
|
}
|
|
942
954
|
|
|
@@ -1625,8 +1637,12 @@ export function useUpdateResource() {
|
|
|
1625
1637
|
const queryClient = useQueryClient()
|
|
1626
1638
|
|
|
1627
1639
|
return useMutation({
|
|
1628
|
-
mutationFn: async ({ kind, namespace, name, yaml }: { kind: string; namespace: string; name: string; yaml: string }) => {
|
|
1629
|
-
const
|
|
1640
|
+
mutationFn: async ({ kind, namespace, name, yaml, force = true }: { kind: string; namespace: string; name: string; yaml: string; force?: boolean }) => {
|
|
1641
|
+
const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
|
|
1642
|
+
if (!force) {
|
|
1643
|
+
url.searchParams.set('force', 'false')
|
|
1644
|
+
}
|
|
1645
|
+
const response = await apiFetch(url.toString(), {
|
|
1630
1646
|
method: 'PUT',
|
|
1631
1647
|
headers: { 'Content-Type': 'text/plain' },
|
|
1632
1648
|
body: yaml,
|
|
@@ -1685,8 +1701,11 @@ export function useDeleteResource() {
|
|
|
1685
1701
|
const queryClient = useQueryClient()
|
|
1686
1702
|
|
|
1687
1703
|
return useMutation({
|
|
1688
|
-
mutationFn: async ({ kind, namespace, name, force }: { kind: string; namespace: string; name: string; force?: boolean }) => {
|
|
1704
|
+
mutationFn: async ({ kind, group, namespace, name, force }: { kind: string; group?: string; namespace: string; name: string; force?: boolean }) => {
|
|
1689
1705
|
const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
|
|
1706
|
+
if (group) {
|
|
1707
|
+
url.searchParams.set('group', group)
|
|
1708
|
+
}
|
|
1690
1709
|
if (force) {
|
|
1691
1710
|
url.searchParams.set('force', 'true')
|
|
1692
1711
|
}
|
|
@@ -1711,6 +1730,44 @@ export function useDeleteResource() {
|
|
|
1711
1730
|
})
|
|
1712
1731
|
}
|
|
1713
1732
|
|
|
1733
|
+
export function useBulkDeleteResources() {
|
|
1734
|
+
const queryClient = useQueryClient()
|
|
1735
|
+
|
|
1736
|
+
return useMutation({
|
|
1737
|
+
mutationFn: async ({ items, force }: { items: Array<{ kind: string; group?: string; namespace: string; name: string }>; force?: boolean }) => {
|
|
1738
|
+
const results = await Promise.allSettled(
|
|
1739
|
+
items.map(async ({ kind, group, namespace, name }) => {
|
|
1740
|
+
const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
|
|
1741
|
+
if (group) url.searchParams.set('group', group)
|
|
1742
|
+
if (force) url.searchParams.set('force', 'true')
|
|
1743
|
+
const response = await apiFetch(url.toString(), { method: 'DELETE' })
|
|
1744
|
+
if (!response.ok) {
|
|
1745
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1746
|
+
throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
|
|
1747
|
+
}
|
|
1748
|
+
return { kind, namespace, name }
|
|
1749
|
+
})
|
|
1750
|
+
)
|
|
1751
|
+
const failed = results.filter(r => r.status === 'rejected')
|
|
1752
|
+
if (failed.length > 0) {
|
|
1753
|
+
throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
|
|
1754
|
+
}
|
|
1755
|
+
return { deleted: items.length }
|
|
1756
|
+
},
|
|
1757
|
+
meta: {
|
|
1758
|
+
errorMessage: 'Failed to delete some resources',
|
|
1759
|
+
successMessage: 'Resources deleted',
|
|
1760
|
+
},
|
|
1761
|
+
// onSettled, not onSuccess — a partial failure still deleted some
|
|
1762
|
+
// resources, and the table must refetch to drop them.
|
|
1763
|
+
onSettled: () => {
|
|
1764
|
+
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
|
1765
|
+
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
|
|
1766
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1767
|
+
},
|
|
1768
|
+
})
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1714
1771
|
// Apply (create or update) a resource from YAML
|
|
1715
1772
|
export interface ApplyResourceResult {
|
|
1716
1773
|
name: string
|
|
@@ -1723,12 +1780,15 @@ export function useApplyResource() {
|
|
|
1723
1780
|
const queryClient = useQueryClient()
|
|
1724
1781
|
|
|
1725
1782
|
return useMutation({
|
|
1726
|
-
mutationFn: async ({ yaml, mode = 'apply', dryRun = false }: { yaml: string; mode?: 'apply' | 'create'; dryRun?: boolean }) => {
|
|
1783
|
+
mutationFn: async ({ yaml, mode = 'apply', dryRun = false, force = false }: { yaml: string; mode?: 'apply' | 'create'; dryRun?: boolean; force?: boolean }) => {
|
|
1727
1784
|
const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
|
|
1728
1785
|
url.searchParams.set('mode', mode)
|
|
1729
1786
|
if (dryRun) {
|
|
1730
1787
|
url.searchParams.set('dryRun', 'true')
|
|
1731
1788
|
}
|
|
1789
|
+
if (force) {
|
|
1790
|
+
url.searchParams.set('force', 'true')
|
|
1791
|
+
}
|
|
1732
1792
|
const response = await apiFetch(url.toString(), {
|
|
1733
1793
|
method: 'POST',
|
|
1734
1794
|
headers: { 'Content-Type': 'text/plain' },
|
|
@@ -4,6 +4,7 @@ import type { ConnectionState } from '../context/ConnectionContext'
|
|
|
4
4
|
import { ContextSwitcher } from './ContextSwitcher'
|
|
5
5
|
import { parseContextName } from '../utils/context-name'
|
|
6
6
|
import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
|
|
7
|
+
import { useAuthMe } from '../api/client'
|
|
7
8
|
|
|
8
9
|
interface ConnectionErrorViewProps {
|
|
9
10
|
connection: ConnectionState
|
|
@@ -165,14 +166,22 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
165
166
|
const authInfo = isAuth ? getAuthHints(connection.context || '') : null
|
|
166
167
|
const errorInfo = authInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
|
|
167
168
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
169
|
+
const { data: authMe } = useAuthMe()
|
|
168
170
|
|
|
169
|
-
//
|
|
171
|
+
// Auto-retry after successful auth. The terminal shell runs on the server
|
|
172
|
+
// host, so the auth command itself fixes the server's credentials in every
|
|
173
|
+
// mode — but the chained retry curl carries no session cookie, so it 401s
|
|
174
|
+
// once /api/connection is auth-gated. Only chain it when auth is *known*
|
|
175
|
+
// disabled (authMe still loading → don't chain a doomed call).
|
|
170
176
|
const retryCmd = `curl -s -X POST http://${window.location.host}/api/connection/retry > /dev/null`
|
|
171
177
|
|
|
172
178
|
const handleAuthInTerminal = () => {
|
|
173
179
|
if (!authInfo?.authCommand) return
|
|
180
|
+
const cmd = authMe?.authEnabled === false
|
|
181
|
+
? `${authInfo.authCommand.command} && ${retryCmd}`
|
|
182
|
+
: authInfo.authCommand.command
|
|
174
183
|
openLocalTerminal({
|
|
175
|
-
initialCommand:
|
|
184
|
+
initialCommand: cmd,
|
|
176
185
|
title: 'Auth',
|
|
177
186
|
})
|
|
178
187
|
}
|
|
@@ -67,18 +67,17 @@ export function UserMenu() {
|
|
|
67
67
|
</p>
|
|
68
68
|
)}
|
|
69
69
|
</div>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
<button
|
|
71
|
+
onClick={handleLogout}
|
|
72
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover transition-colors"
|
|
73
|
+
>
|
|
74
|
+
<LogOut className="w-3.5 h-3.5" />
|
|
75
|
+
Logout
|
|
76
|
+
</button>
|
|
77
|
+
{authMe.authMode === 'proxy' && !authMe.proxyLogoutConfigured && (
|
|
78
|
+
<p className="px-3 py-1.5 text-[11px] text-theme-text-tertiary border-t border-theme-border">
|
|
79
|
+
Logout clears the Radar session. The auth proxy may sign you back in automatically.
|
|
73
80
|
</p>
|
|
74
|
-
) : (
|
|
75
|
-
<button
|
|
76
|
-
onClick={handleLogout}
|
|
77
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover transition-colors"
|
|
78
|
-
>
|
|
79
|
-
<LogOut className="w-3.5 h-3.5" />
|
|
80
|
-
Logout
|
|
81
|
-
</button>
|
|
82
81
|
)}
|
|
83
82
|
</div>
|
|
84
83
|
)}
|
|
@@ -52,7 +52,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
name: 'get_resource',
|
|
55
|
-
desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics
|
|
55
|
+
desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics data.',
|
|
56
56
|
params: [
|
|
57
57
|
{ arg: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
|
|
58
58
|
{ arg: 'name', required: true, desc: 'resource name' },
|
|
@@ -109,10 +109,10 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
name: 'diagnose',
|
|
112
|
-
desc: 'One-call
|
|
112
|
+
desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers get status summary + parsed related issues.',
|
|
113
113
|
params: [
|
|
114
|
-
{ arg: 'kind', required: true, desc: 'pod, deployment, statefulset, or
|
|
115
|
-
{ arg: 'namespace', required: true, desc: '
|
|
114
|
+
{ arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or helmrelease' },
|
|
115
|
+
{ arg: 'namespace', required: true, desc: 'resource namespace' },
|
|
116
116
|
{ arg: 'name', required: true, desc: 'resource name' },
|
|
117
117
|
{ arg: 'container', desc: 'specific container (defaults to all)' },
|
|
118
118
|
{ arg: 'tail_lines', desc: 'lines per pod/stream (default 100)' },
|
|
@@ -163,10 +163,10 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
163
163
|
},
|
|
164
164
|
{
|
|
165
165
|
name: 'list_packages',
|
|
166
|
-
desc: 'Unified "what\'s installed" view across Helm releases, workload labels, CRD registrations, and GitOps declarations (Argo + Flux), with sources, versions, and
|
|
166
|
+
desc: 'Unified "what\'s installed" view across Helm releases, workload labels, CRD registrations, and GitOps declarations (Argo + Flux), with sources, versions, health, and a sourceLegend for the stable source codes.',
|
|
167
167
|
params: [
|
|
168
168
|
{ arg: 'namespace', desc: 'filter to a specific namespace' },
|
|
169
|
-
{ arg: 'source', desc: 'H
|
|
169
|
+
{ arg: 'source', desc: 'H/helm, L/labels, C/crds, A/argocd, F/fluxcd' },
|
|
170
170
|
{ arg: 'chart', desc: 'case-insensitive chart-name substring' },
|
|
171
171
|
],
|
|
172
172
|
},
|
|
@@ -253,12 +253,29 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
253
253
|
{
|
|
254
254
|
name: 'apply_resource',
|
|
255
255
|
write: true,
|
|
256
|
-
desc: 'Create or update a resource from YAML. apply mode is
|
|
256
|
+
desc: 'Create or update a resource from YAML. apply mode is server-side apply and reports field ownership conflicts by default; create mode fails if it exists. Multi-document YAML returns per-document status on partial failure.',
|
|
257
257
|
params: [
|
|
258
258
|
{ arg: 'yaml', required: true, desc: 'YAML manifest (multi-document with --- supported)' },
|
|
259
259
|
{ arg: 'mode', desc: 'apply (default) or create' },
|
|
260
|
-
{ arg: 'dry_run', desc: 'validate without persisting' },
|
|
260
|
+
{ arg: 'dry_run', desc: 'validate and preview without persisting' },
|
|
261
261
|
{ arg: 'namespace', desc: 'override namespace for the resource' },
|
|
262
|
+
{ arg: 'verify', desc: 'return post-mutation state; on dry_run return preview diff (default true)' },
|
|
263
|
+
{ arg: 'force', desc: 'force SSA field ownership conflicts and take ownership from other managers (default false)' },
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: 'patch_resource',
|
|
268
|
+
write: true,
|
|
269
|
+
desc: 'Patch one existing resource with JSON Patch, JSON Merge Patch, or built-in-kind strategic merge patch for precise edits without rewriting the full manifest.',
|
|
270
|
+
params: [
|
|
271
|
+
{ arg: 'kind', required: true, desc: 'resource kind, e.g. Deployment, Service, ConfigMap' },
|
|
272
|
+
{ arg: 'name', required: true, desc: 'resource name' },
|
|
273
|
+
{ arg: 'namespace', desc: 'resource namespace; omit for cluster-scoped resources' },
|
|
274
|
+
{ arg: 'group', desc: 'API group when the kind is ambiguous' },
|
|
275
|
+
{ arg: 'patch_type', desc: 'json (default), merge, or strategic' },
|
|
276
|
+
{ arg: 'patch', required: true, desc: 'JSON patch body' },
|
|
277
|
+
{ arg: 'dry_run', desc: 'validate and preview without persisting' },
|
|
278
|
+
{ arg: 'verify', desc: 'return post-patch state; on dry_run return preview diff (default true)' },
|
|
262
279
|
],
|
|
263
280
|
},
|
|
264
281
|
{
|
|
@@ -10,9 +10,11 @@ interface LogsViewerProps {
|
|
|
10
10
|
podName: string
|
|
11
11
|
containers: string[]
|
|
12
12
|
initialContainer?: string
|
|
13
|
+
/** Start streaming on mount. Default true — callers pass false for terminal (completed/failed) pods. */
|
|
14
|
+
autoStream?: boolean
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export function LogsViewer({ namespace, podName, containers, initialContainer }: LogsViewerProps) {
|
|
17
|
+
export function LogsViewer({ namespace, podName, containers, initialContainer, autoStream = true }: LogsViewerProps) {
|
|
16
18
|
const desktopDownload = useDesktopDownload()
|
|
17
19
|
const { theme } = useTheme()
|
|
18
20
|
|
|
@@ -42,6 +44,7 @@ export function LogsViewer({ namespace, podName, containers, initialContainer }:
|
|
|
42
44
|
createStream={makeStream}
|
|
43
45
|
overrideDownload={desktopDownload}
|
|
44
46
|
forceDark={theme === 'dark' ? true : undefined}
|
|
47
|
+
autoStream={autoStream}
|
|
45
48
|
/>
|
|
46
49
|
)
|
|
47
50
|
}
|
|
@@ -9,9 +9,11 @@ interface WorkloadLogsViewerProps {
|
|
|
9
9
|
kind: string
|
|
10
10
|
namespace: string
|
|
11
11
|
name: string
|
|
12
|
+
/** Start streaming on mount. Default true — workload logs are aggregated from live pods. */
|
|
13
|
+
autoStream?: boolean
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
export function WorkloadLogsViewer({ kind, namespace, name }: WorkloadLogsViewerProps) {
|
|
16
|
+
export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true }: WorkloadLogsViewerProps) {
|
|
15
17
|
const desktopDownload = useDesktopDownload()
|
|
16
18
|
const { theme } = useTheme()
|
|
17
19
|
|
|
@@ -38,6 +40,7 @@ export function WorkloadLogsViewer({ kind, namespace, name }: WorkloadLogsViewer
|
|
|
38
40
|
createStream={makeStream}
|
|
39
41
|
overrideDownload={desktopDownload}
|
|
40
42
|
forceDark={theme === 'dark' ? true : undefined}
|
|
43
|
+
autoStream={autoStream}
|
|
41
44
|
/>
|
|
42
45
|
)
|
|
43
46
|
}
|
|
@@ -48,14 +48,12 @@ export function PrometheusChartsGrid({
|
|
|
48
48
|
name,
|
|
49
49
|
resource,
|
|
50
50
|
}: PrometheusChartsGridProps) {
|
|
51
|
-
// Node-kind workloads don't have container restart semantics — KSM would
|
|
52
|
-
// return empty series anyway, but suppressing the lane spares the query.
|
|
53
|
-
const showRestartLane = kind !== 'Node'
|
|
54
51
|
useAutoPromConnect()
|
|
55
52
|
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
56
53
|
const connectMutation = usePrometheusConnect()
|
|
57
54
|
const isConnected = status?.connected === true
|
|
58
55
|
const isSupported = SUPPORTED_KINDS.has(kind)
|
|
56
|
+
const showRestartLane = isSupported && kind !== 'Node'
|
|
59
57
|
|
|
60
58
|
const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
|
|
61
59
|
const [diskExpanded, setDiskExpanded] = useState(false)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useMemo, useCallback, useEffect } from 'react'
|
|
2
2
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { useQuery } from '@tanstack/react-query'
|
|
4
|
-
import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
|
|
4
|
+
import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources } from '../../api/client'
|
|
5
5
|
import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
|
|
6
6
|
import { useAPIResources } from '../../api/apiResources'
|
|
7
7
|
import { initNavigationMap } from '@skyhook-io/k8s-ui'
|
|
@@ -146,6 +146,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
146
146
|
const openLogs = useOpenLogs()
|
|
147
147
|
const openWorkloadLogs = useOpenWorkloadLogs()
|
|
148
148
|
|
|
149
|
+
// Bulk delete
|
|
150
|
+
const bulkDeleteMutation = useBulkDeleteResources()
|
|
151
|
+
|
|
149
152
|
// Navigation adapter. k8s-ui constructs paths from `basePath` (which
|
|
150
153
|
// includes the router basename so they line up with window.location.pathname
|
|
151
154
|
// for path-equality checks) and from `window.location.pathname` directly.
|
|
@@ -224,6 +227,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
224
227
|
onOpenWorkloadLogs={openWorkloadLogs}
|
|
225
228
|
// Create resource
|
|
226
229
|
onCreateResource={handleCreateResource}
|
|
230
|
+
// Bulk operations
|
|
231
|
+
onBulkDelete={(items, options) => bulkDeleteMutation.mutate({ items, force: options?.force }, { onSuccess: options?.onSuccess })}
|
|
232
|
+
isBulkDeleting={bulkDeleteMutation.isPending}
|
|
227
233
|
/>
|
|
228
234
|
<CreateResourceDialog
|
|
229
235
|
open={createDialogOpen}
|
|
@@ -1,18 +1,45 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
1
2
|
import { ServiceRenderer as BaseServiceRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
|
|
2
3
|
import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
|
|
4
|
+
import { useResources } from '../../../api/client'
|
|
5
|
+
import type { ResourceRef } from '../../../types'
|
|
3
6
|
|
|
4
7
|
interface ServiceRendererProps {
|
|
5
8
|
data: any
|
|
6
9
|
onCopy: (text: string, label: string) => void
|
|
7
10
|
copied: string | null
|
|
11
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
8
12
|
}
|
|
9
13
|
|
|
10
|
-
export function ServiceRenderer({ data, onCopy, copied }: ServiceRendererProps) {
|
|
14
|
+
export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRendererProps) {
|
|
15
|
+
const namespace = data.metadata?.namespace
|
|
16
|
+
const serviceName = data.metadata?.name
|
|
17
|
+
const spec = data.spec || {}
|
|
18
|
+
const shouldLoadEndpointSlices = Boolean(
|
|
19
|
+
namespace &&
|
|
20
|
+
serviceName &&
|
|
21
|
+
spec.type !== 'ExternalName' &&
|
|
22
|
+
(!spec.selector || Object.keys(spec.selector).length === 0)
|
|
23
|
+
)
|
|
24
|
+
const { data: endpointSlices, isLoading: endpointSlicesLoading } = useResources<any>(
|
|
25
|
+
'endpointslices',
|
|
26
|
+
namespace,
|
|
27
|
+
'discovery.k8s.io',
|
|
28
|
+
{ enabled: shouldLoadEndpointSlices, refetchInterval: 30000 }
|
|
29
|
+
)
|
|
30
|
+
const matchingEndpointSlices = useMemo(
|
|
31
|
+
() => (endpointSlices || []).filter((slice: any) => slice.metadata?.labels?.['kubernetes.io/service-name'] === serviceName),
|
|
32
|
+
[endpointSlices, serviceName]
|
|
33
|
+
)
|
|
34
|
+
|
|
11
35
|
return (
|
|
12
36
|
<BaseServiceRenderer
|
|
13
37
|
data={data}
|
|
14
38
|
onCopy={onCopy}
|
|
15
39
|
copied={copied}
|
|
40
|
+
endpointSlices={matchingEndpointSlices}
|
|
41
|
+
endpointSlicesLoading={endpointSlicesLoading}
|
|
42
|
+
onNavigate={onNavigate}
|
|
16
43
|
renderPortAction={({ namespace, serviceName, port, protocol }) => (
|
|
17
44
|
<PortForwardInlineButton
|
|
18
45
|
namespace={namespace}
|
|
@@ -705,6 +705,12 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
|
|
|
705
705
|
return names
|
|
706
706
|
}, [resource])
|
|
707
707
|
|
|
708
|
+
// A terminated pod has nothing to follow — only stream live ones. Wait for
|
|
709
|
+
// the phase to be known so a completed pod isn't briefly streamed while the
|
|
710
|
+
// resource is still loading.
|
|
711
|
+
const phase = resource?.status?.phase
|
|
712
|
+
const autoStream = !!phase && phase !== 'Succeeded' && phase !== 'Failed'
|
|
713
|
+
|
|
708
714
|
useEffect(() => {
|
|
709
715
|
if (initialContainer && containers.includes(initialContainer)) {
|
|
710
716
|
onConsumeInitialContainer?.()
|
|
@@ -718,6 +724,7 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
|
|
|
718
724
|
podName={name}
|
|
719
725
|
containers={containers}
|
|
720
726
|
initialContainer={initialContainer || undefined}
|
|
727
|
+
autoStream={autoStream}
|
|
721
728
|
/>
|
|
722
729
|
</div>
|
|
723
730
|
)
|
|
@@ -742,6 +749,13 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
|
|
|
742
749
|
const { data: logsData } = usePodLogs(podNamespace, selectedPod || '', { tailLines: 1 })
|
|
743
750
|
const containers = logsData?.containers || []
|
|
744
751
|
|
|
752
|
+
// A terminated pod (common for Job/CronJob children) has nothing to follow —
|
|
753
|
+
// only stream live ones. Wait for the pod to load before deciding so we don't
|
|
754
|
+
// briefly auto-stream a completed pod while its phase is still unknown.
|
|
755
|
+
const { data: selectedPodResource } = useResource<any>('Pod', podNamespace, selectedPod || '')
|
|
756
|
+
const phase = selectedPodResource?.status?.phase
|
|
757
|
+
const autoStream = !!phase && phase !== 'Succeeded' && phase !== 'Failed'
|
|
758
|
+
|
|
745
759
|
if (pods.length === 0) {
|
|
746
760
|
return (
|
|
747
761
|
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
|
|
@@ -779,6 +793,7 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
|
|
|
779
793
|
podName={selectedPod}
|
|
780
794
|
containers={containers}
|
|
781
795
|
initialContainer={initialContainer || undefined}
|
|
796
|
+
autoStream={autoStream}
|
|
782
797
|
/>
|
|
783
798
|
</div>
|
|
784
799
|
)}
|
|
@@ -922,7 +937,7 @@ function DrawerMetricsContent({ kind, namespace, name, resource }: {
|
|
|
922
937
|
resource: any
|
|
923
938
|
}) {
|
|
924
939
|
const [chartRange, setChartRange] = useState<import('../../api/client').PrometheusTimeRange>('1h')
|
|
925
|
-
const showRestartLane = kind !== 'Node'
|
|
940
|
+
const showRestartLane = isPrometheusSupported(kind) && kind !== 'Node'
|
|
926
941
|
|
|
927
942
|
return (
|
|
928
943
|
<div className="flex flex-col h-full">
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'
|
|
2
2
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
3
3
|
import type { ContextInfo } from '../types'
|
|
4
|
-
import { getApiBase
|
|
4
|
+
import { getApiBase } from '../api/config'
|
|
5
|
+
import { apiFetch } from '../api/client'
|
|
5
6
|
|
|
6
7
|
export type ConnectionStateType = 'connected' | 'disconnected' | 'connecting'
|
|
7
8
|
|
|
@@ -29,10 +30,11 @@ interface ConnectionContextValue {
|
|
|
29
30
|
const ConnectionContext = createContext<ConnectionContextValue | null>(null)
|
|
30
31
|
|
|
31
32
|
async function fetchConnectionStatus(): Promise<ConnectionStatusResponse> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
// apiFetch handles a 401 globally (re-auth redirect). These endpoints are
|
|
34
|
+
// no longer auth-exempt, so a session that expires while the connection-
|
|
35
|
+
// error screen is parked open must route through that path rather than
|
|
36
|
+
// surfacing as a misleading "cannot connect to cluster" error.
|
|
37
|
+
const response = await apiFetch(`${getApiBase()}/connection`)
|
|
36
38
|
if (!response.ok) {
|
|
37
39
|
throw new Error('Failed to fetch connection status')
|
|
38
40
|
}
|
|
@@ -40,10 +42,8 @@ async function fetchConnectionStatus(): Promise<ConnectionStatusResponse> {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
async function retryConnection(): Promise<ConnectionState> {
|
|
43
|
-
const response = await
|
|
45
|
+
const response = await apiFetch(`${getApiBase()}/connection/retry`, {
|
|
44
46
|
method: 'POST',
|
|
45
|
-
credentials: getCredentialsMode(),
|
|
46
|
-
headers: getAuthHeaders(),
|
|
47
47
|
})
|
|
48
48
|
if (!response.ok) {
|
|
49
49
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|