@skyhook-io/radar-app 1.3.5 → 1.4.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 +1 -1
- package/src/App.tsx +39 -5
- package/src/api/client.ts +64 -3
- package/src/components/ConnectionErrorView.tsx +11 -2
- package/src/components/UserMenu.tsx +10 -11
- package/src/components/applications/ApplicationsView.tsx +218 -0
- package/src/components/home/mcpToolCatalog.ts +6 -6
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/resources/ResourcesView.tsx +7 -1
- package/src/components/workload/WorkloadView.tsx +45 -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'
|
|
@@ -19,6 +19,7 @@ import { CostView } from './components/cost/CostView'
|
|
|
19
19
|
import { AuditView } from './components/audit/AuditView'
|
|
20
20
|
import { IssuesPane } from './components/issues/IssuesPane'
|
|
21
21
|
import { GitOpsView } from './components/gitops/GitOpsView'
|
|
22
|
+
import { ApplicationsView } from './components/applications/ApplicationsView'
|
|
22
23
|
import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
|
|
23
24
|
import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
|
|
24
25
|
import { DockProvider, BottomDock, useDock, useDockReservedHeight, useOpenLocalTerminal } from './components/dock'
|
|
@@ -93,7 +94,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
93
94
|
|
|
94
95
|
// Convert API resource name back to topology node ID prefix
|
|
95
96
|
// Extended MainView type that includes traffic and cost
|
|
96
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare' | 'issues'
|
|
97
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare' | 'issues' | 'applications'
|
|
97
98
|
|
|
98
99
|
// Extract view from URL path
|
|
99
100
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
@@ -108,6 +109,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
108
109
|
if (path === 'workload') return 'workload'
|
|
109
110
|
if (path === 'audit') return 'audit'
|
|
110
111
|
if (path === 'gitops') return 'gitops'
|
|
112
|
+
if (path === 'applications') return 'applications'
|
|
111
113
|
if (path === 'compare') return 'compare'
|
|
112
114
|
if (path === 'issues') return 'issues'
|
|
113
115
|
return 'home'
|
|
@@ -416,6 +418,23 @@ function AppInner() {
|
|
|
416
418
|
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
417
419
|
}, [searchParams, navigate])
|
|
418
420
|
|
|
421
|
+
// From the Issues queue: a GitOps reconciler subject (Argo Application / Flux
|
|
422
|
+
// Kustomization / HelmRelease) routes to its rich detail page (tree + insights
|
|
423
|
+
// + ops), not the generic resource drawer that's a dead-end for it. Member
|
|
424
|
+
// resources (Pods, Services, …) fall through to the standard resource view.
|
|
425
|
+
const navigateFromIssue = useCallback((resource: SelectedResource) => {
|
|
426
|
+
const gitOpsPath = gitOpsRouteForResource({
|
|
427
|
+
apiVersion: resource.group ? `${resource.group}/v1` : 'v1',
|
|
428
|
+
kind: resource.kind,
|
|
429
|
+
metadata: { namespace: resource.namespace ?? '', name: resource.name },
|
|
430
|
+
})
|
|
431
|
+
if (gitOpsPath) {
|
|
432
|
+
navigate(gitOpsPath)
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
navigateToResourceList(resource)
|
|
436
|
+
}, [navigate, navigateToResourceList])
|
|
437
|
+
|
|
419
438
|
// Collapse from expanded WorkloadView back to drawer
|
|
420
439
|
const handleCollapseFromExpanded = useCallback(() => {
|
|
421
440
|
suppressViewClearRef.current = true
|
|
@@ -434,7 +453,7 @@ function AppInner() {
|
|
|
434
453
|
const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
|
|
435
454
|
|
|
436
455
|
// View switching keyboard shortcuts
|
|
437
|
-
const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'gitops', 'traffic', 'cost', 'audit']
|
|
456
|
+
const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'gitops', 'applications', 'traffic', 'cost', 'audit']
|
|
438
457
|
useRegisterShortcuts([
|
|
439
458
|
...views.map((view, i) => ({
|
|
440
459
|
id: `view-${view}`,
|
|
@@ -1190,6 +1209,10 @@ function AppInner() {
|
|
|
1190
1209
|
{ view: 'timeline' as const, icon: Clock, label: 'Timeline' },
|
|
1191
1210
|
{ view: 'helm' as const, icon: Package, label: 'Helm' },
|
|
1192
1211
|
{ view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
|
|
1212
|
+
// Applications is intentionally hidden from the pill bar for now —
|
|
1213
|
+
// the bar is full, and the view's primary home is Cloud's fleet
|
|
1214
|
+
// rail. The view still exists and is reachable via /applications
|
|
1215
|
+
// and the view-switching shortcuts. Same treatment as Cost below.
|
|
1193
1216
|
{ view: 'traffic' as const, icon: Activity, label: 'Traffic' },
|
|
1194
1217
|
// Cost is intentionally hidden from the pill bar for now — the view still
|
|
1195
1218
|
// exists and is reachable via /cost, the Home dashboard card, and the
|
|
@@ -1637,6 +1660,16 @@ function AppInner() {
|
|
|
1637
1660
|
/>
|
|
1638
1661
|
)}
|
|
1639
1662
|
|
|
1663
|
+
{/* Applications view — deployable software grouped by app/release evidence */}
|
|
1664
|
+
{mainView === 'applications' && (
|
|
1665
|
+
<ApplicationsView
|
|
1666
|
+
namespaces={namespaces}
|
|
1667
|
+
onOpenResource={(resource) => {
|
|
1668
|
+
setSelectedResource(resource)
|
|
1669
|
+
}}
|
|
1670
|
+
/>
|
|
1671
|
+
)}
|
|
1672
|
+
|
|
1640
1673
|
{/* Traffic view */}
|
|
1641
1674
|
{mainView === 'traffic' && (
|
|
1642
1675
|
<TrafficView namespaces={namespaces} />
|
|
@@ -1667,12 +1700,13 @@ function AppInner() {
|
|
|
1667
1700
|
|
|
1668
1701
|
{/* Issues — per-cluster live triage queue (hidden route: not yet in the
|
|
1669
1702
|
nav `views` list; reachable at /issues). Same shared <IssuesView> the
|
|
1670
|
-
Hub fleet uses;
|
|
1703
|
+
Hub fleet uses; a GitOps reconciler subject routes to its detail page,
|
|
1704
|
+
other resources open the standard resource view. */}
|
|
1671
1705
|
{mainView === 'issues' && (
|
|
1672
1706
|
<IssuesPane
|
|
1673
1707
|
namespaces={namespaces}
|
|
1674
1708
|
onBack={() => setMainView('home')}
|
|
1675
|
-
onNavigateToResource={
|
|
1709
|
+
onNavigateToResource={navigateFromIssue}
|
|
1676
1710
|
/>
|
|
1677
1711
|
)}
|
|
1678
1712
|
|
package/src/api/client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type { AppRow } from '@skyhook-io/k8s-ui'
|
|
2
3
|
import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
|
|
3
4
|
import { showApiError, showApiSuccess } from '../components/ui/Toast'
|
|
4
5
|
import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
|
|
@@ -35,7 +36,7 @@ import { pluralToKind } from '../utils/navigation'
|
|
|
35
36
|
// and handles 401 responses globally. Merges caller-provided headers with
|
|
36
37
|
// auth headers from the config module so library consumers (Radar Hub) can
|
|
37
38
|
// inject Authorization bearer tokens without each call site knowing.
|
|
38
|
-
function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
39
|
+
export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
39
40
|
const headers = new Headers(init?.headers)
|
|
40
41
|
for (const [k, v] of Object.entries(getAuthHeaders())) {
|
|
41
42
|
if (!headers.has(k)) headers.set(k, v)
|
|
@@ -237,7 +238,7 @@ export interface DashboardCRDCount {
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
// 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'
|
|
241
|
+
import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check, Issue, IssueRecentChange } from '@skyhook-io/k8s-ui'
|
|
241
242
|
export type DashboardAudit = AuditCardData
|
|
242
243
|
export type { AuditFinding, ResourceGroup, CheckMeta, Check }
|
|
243
244
|
|
|
@@ -349,6 +350,8 @@ export interface IssuesResponse {
|
|
|
349
350
|
issues: Issue[]
|
|
350
351
|
total?: number
|
|
351
352
|
total_matched?: number
|
|
353
|
+
recent_changes?: IssueRecentChange[]
|
|
354
|
+
recent_changes_reason?: string
|
|
352
355
|
// Present only when RBAC visibility is incomplete (absent = full access).
|
|
353
356
|
// state 'degraded' means core workload reads are denied, so an empty list may
|
|
354
357
|
// mean "can't see" rather than "nothing broken" — the UI must say so.
|
|
@@ -738,6 +741,10 @@ export interface AuthMe {
|
|
|
738
741
|
/** Pre-computed Cloud tier from `cloud:<tier>` group prefix.
|
|
739
742
|
* Absent when not running under Cloud (OSS, OIDC, no role group). */
|
|
740
743
|
cloudRole?: CloudRole
|
|
744
|
+
/** Proxy mode only: whether an upstream sign-out URL is configured.
|
|
745
|
+
* When false, logout clears Radar's cookie but the proxy may re-auth
|
|
746
|
+
* the same user on the next request. */
|
|
747
|
+
proxyLogoutConfigured?: boolean
|
|
741
748
|
}
|
|
742
749
|
|
|
743
750
|
export function useAuthMe() {
|
|
@@ -847,6 +854,19 @@ export function useTopology(namespaces: string[], viewMode: string = 'resources'
|
|
|
847
854
|
})
|
|
848
855
|
}
|
|
849
856
|
|
|
857
|
+
export function useApplications(namespaces: string[]) {
|
|
858
|
+
const params = new URLSearchParams()
|
|
859
|
+
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
860
|
+
const queryString = params.toString()
|
|
861
|
+
|
|
862
|
+
return useQuery<{ applications: AppRow[] }>({
|
|
863
|
+
queryKey: ['applications', namespaces],
|
|
864
|
+
queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
|
|
865
|
+
staleTime: 30_000,
|
|
866
|
+
refetchInterval: 60_000,
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
|
|
850
870
|
export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
|
|
851
871
|
const ns = namespace || '_'
|
|
852
872
|
const params = new URLSearchParams()
|
|
@@ -1695,8 +1715,11 @@ export function useDeleteResource() {
|
|
|
1695
1715
|
const queryClient = useQueryClient()
|
|
1696
1716
|
|
|
1697
1717
|
return useMutation({
|
|
1698
|
-
mutationFn: async ({ kind, namespace, name, force }: { kind: string; namespace: string; name: string; force?: boolean }) => {
|
|
1718
|
+
mutationFn: async ({ kind, group, namespace, name, force }: { kind: string; group?: string; namespace: string; name: string; force?: boolean }) => {
|
|
1699
1719
|
const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
|
|
1720
|
+
if (group) {
|
|
1721
|
+
url.searchParams.set('group', group)
|
|
1722
|
+
}
|
|
1700
1723
|
if (force) {
|
|
1701
1724
|
url.searchParams.set('force', 'true')
|
|
1702
1725
|
}
|
|
@@ -1721,6 +1744,44 @@ export function useDeleteResource() {
|
|
|
1721
1744
|
})
|
|
1722
1745
|
}
|
|
1723
1746
|
|
|
1747
|
+
export function useBulkDeleteResources() {
|
|
1748
|
+
const queryClient = useQueryClient()
|
|
1749
|
+
|
|
1750
|
+
return useMutation({
|
|
1751
|
+
mutationFn: async ({ items, force }: { items: Array<{ kind: string; group?: string; namespace: string; name: string }>; force?: boolean }) => {
|
|
1752
|
+
const results = await Promise.allSettled(
|
|
1753
|
+
items.map(async ({ kind, group, namespace, name }) => {
|
|
1754
|
+
const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
|
|
1755
|
+
if (group) url.searchParams.set('group', group)
|
|
1756
|
+
if (force) url.searchParams.set('force', 'true')
|
|
1757
|
+
const response = await apiFetch(url.toString(), { method: 'DELETE' })
|
|
1758
|
+
if (!response.ok) {
|
|
1759
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1760
|
+
throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
|
|
1761
|
+
}
|
|
1762
|
+
return { kind, namespace, name }
|
|
1763
|
+
})
|
|
1764
|
+
)
|
|
1765
|
+
const failed = results.filter(r => r.status === 'rejected')
|
|
1766
|
+
if (failed.length > 0) {
|
|
1767
|
+
throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
|
|
1768
|
+
}
|
|
1769
|
+
return { deleted: items.length }
|
|
1770
|
+
},
|
|
1771
|
+
meta: {
|
|
1772
|
+
errorMessage: 'Failed to delete some resources',
|
|
1773
|
+
successMessage: 'Resources deleted',
|
|
1774
|
+
},
|
|
1775
|
+
// onSettled, not onSuccess — a partial failure still deleted some
|
|
1776
|
+
// resources, and the table must refetch to drop them.
|
|
1777
|
+
onSettled: () => {
|
|
1778
|
+
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
|
1779
|
+
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
|
|
1780
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1781
|
+
},
|
|
1782
|
+
})
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1724
1785
|
// Apply (create or update) a resource from YAML
|
|
1725
1786
|
export interface ApplyResourceResult {
|
|
1726
1787
|
name: string
|
|
@@ -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
|
)}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react'
|
|
2
|
+
import { useSearchParams } from 'react-router-dom'
|
|
3
|
+
import {
|
|
4
|
+
ApplicationsList,
|
|
5
|
+
ApplicationDetail,
|
|
6
|
+
CenteredEmpty,
|
|
7
|
+
useToast,
|
|
8
|
+
orderEnvs,
|
|
9
|
+
matchWorkloadAcrossInstances,
|
|
10
|
+
workloadKey,
|
|
11
|
+
healthOf,
|
|
12
|
+
compareVersions,
|
|
13
|
+
type AppRow,
|
|
14
|
+
type AppIdentityInstance,
|
|
15
|
+
type SelectedAppWorkload,
|
|
16
|
+
type SelectedResource,
|
|
17
|
+
} from '@skyhook-io/k8s-ui'
|
|
18
|
+
import { Boxes } from 'lucide-react'
|
|
19
|
+
import { useApplications, useTopology } from '../../api/client'
|
|
20
|
+
import { kindToPlural } from '../../utils/navigation'
|
|
21
|
+
import { WorkloadView } from '../workload/WorkloadView'
|
|
22
|
+
|
|
23
|
+
interface ApplicationsViewProps {
|
|
24
|
+
namespaces: string[]
|
|
25
|
+
onOpenResource: (resource: SelectedResource) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
|
|
29
|
+
const query = useApplications(namespaces)
|
|
30
|
+
const apps = query.data?.applications ?? []
|
|
31
|
+
|
|
32
|
+
// Which app is open lives in the URL (?app=<key>) so the detail view is
|
|
33
|
+
// deep-linkable and the browser back button returns to the list. Opening or
|
|
34
|
+
// closing an app also clears the per-app params (workload, tab).
|
|
35
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
36
|
+
const selectedKey = searchParams.get('app')
|
|
37
|
+
const selected = useMemo(() => apps.find((a) => a.key === selectedKey) ?? null, [apps, selectedKey])
|
|
38
|
+
|
|
39
|
+
const selectApp = useCallback(
|
|
40
|
+
(key: string | null) => {
|
|
41
|
+
const params = new URLSearchParams(searchParams)
|
|
42
|
+
if (key) params.set('app', key)
|
|
43
|
+
else params.delete('app')
|
|
44
|
+
params.delete('workload')
|
|
45
|
+
params.delete('tab')
|
|
46
|
+
setSearchParams(params)
|
|
47
|
+
},
|
|
48
|
+
[searchParams, setSearchParams],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// A stale ?app= (uninstalled/renamed app, or a link from another cluster)
|
|
52
|
+
// would leave the URL lying under the list view — clear it once data is
|
|
53
|
+
// fresh. Never during load, so a slow fetch can't eject a valid deep link.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (selectedKey && !selected && query.isSuccess) {
|
|
56
|
+
const params = new URLSearchParams(searchParams)
|
|
57
|
+
params.delete('app')
|
|
58
|
+
params.delete('workload')
|
|
59
|
+
params.delete('tab')
|
|
60
|
+
setSearchParams(params, { replace: true })
|
|
61
|
+
}
|
|
62
|
+
}, [selectedKey, selected, query.isSuccess, searchParams, setSearchParams])
|
|
63
|
+
|
|
64
|
+
if (selectedKey && selected) {
|
|
65
|
+
return <AppDetailRoute app={selected} apps={apps} onBack={() => selectApp(null)} onOpenResource={onOpenResource} />
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6">
|
|
70
|
+
<header className="mb-4 flex flex-col gap-1">
|
|
71
|
+
<h1 className="text-xl font-semibold text-theme-text-primary">Applications</h1>
|
|
72
|
+
<p className="max-w-3xl text-sm text-theme-text-secondary">Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence.</p>
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
{query.isLoading ? (
|
|
76
|
+
<CenteredEmpty icon={Boxes} headline="Loading applications…" />
|
|
77
|
+
) : query.error ? (
|
|
78
|
+
<CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
|
|
79
|
+
) : apps.length === 0 ? (
|
|
80
|
+
<CenteredEmpty
|
|
81
|
+
icon={Boxes}
|
|
82
|
+
headline="No applications detected yet"
|
|
83
|
+
body="Deploy services, workers, or jobs to this cluster to see them grouped by app."
|
|
84
|
+
/>
|
|
85
|
+
) : (
|
|
86
|
+
<ApplicationsList apps={apps} onSelect={selectApp} />
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// AppDetailRoute wires the OSS data hooks the shared ApplicationDetail can't:
|
|
93
|
+
// the resources-view topology over the app's namespaces (for the app graph)
|
|
94
|
+
// and the per-workload WorkloadView (which fetches its own topology for the
|
|
95
|
+
// Topology tab). Split out so useTopology runs unconditionally (Rules of Hooks).
|
|
96
|
+
function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; apps: AppRow[]; onBack: () => void; onOpenResource: (resource: SelectedResource) => void }) {
|
|
97
|
+
const appNamespaces = useMemo(
|
|
98
|
+
() => Array.from(new Set((app.workloads ?? []).map((w) => w.namespace).filter(Boolean))).sort(),
|
|
99
|
+
[app.workloads],
|
|
100
|
+
)
|
|
101
|
+
const { data: topology, isLoading: topologyLoading } = useTopology(appNamespaces, 'resources', { enabled: appNamespaces.length > 0 })
|
|
102
|
+
|
|
103
|
+
// The selected workload (?workload=<key>) lives in the URL too: deep-linkable,
|
|
104
|
+
// and back returns from a workload's runtime to the app graph. Clearing it
|
|
105
|
+
// also drops the workload's tab param.
|
|
106
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
107
|
+
const selectedWorkloadKey = searchParams.get('workload')
|
|
108
|
+
const selectWorkload = useCallback(
|
|
109
|
+
(key: string | null) => {
|
|
110
|
+
const params = new URLSearchParams(searchParams)
|
|
111
|
+
// Always drop the workload's tab: a fresh workload opens on its overview,
|
|
112
|
+
// and clearing back to the graph leaves no stale tab on the route.
|
|
113
|
+
params.delete('tab')
|
|
114
|
+
if (key) params.set('workload', key)
|
|
115
|
+
else params.delete('workload')
|
|
116
|
+
setSearchParams(params)
|
|
117
|
+
},
|
|
118
|
+
[searchParams, setSearchParams],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
// App identity switcher data: this instance's siblings (ladder-ordered
|
|
122
|
+
// digests). It switches between REAL instances — ?app= changes, deep links
|
|
123
|
+
// stay instance-keyed.
|
|
124
|
+
const { showToast } = useToast();
|
|
125
|
+
const identityInstances = useMemo<AppIdentityInstance[] | null>(() => {
|
|
126
|
+
const fam = app.identity;
|
|
127
|
+
if (!fam) return null;
|
|
128
|
+
const sibs = apps.filter((a) => a.identity?.key === fam.key);
|
|
129
|
+
if (sibs.length < 2) return null;
|
|
130
|
+
const newest = (a: AppRow) =>
|
|
131
|
+
(a.versions ?? []).reduce<string | undefined>((best, v) => (!best || compareVersions(v, best) === 1 ? v : best), undefined) ?? a.appVersion;
|
|
132
|
+
const order = orderEnvs(sibs.map((a) => a.identity!.env));
|
|
133
|
+
return [...sibs]
|
|
134
|
+
.sort((a, b) => order.indexOf(a.identity!.env) - order.indexOf(b.identity!.env) || a.name.localeCompare(b.name))
|
|
135
|
+
.map((a) => ({
|
|
136
|
+
appKey: a.key,
|
|
137
|
+
name: a.name,
|
|
138
|
+
env: a.identity!.env,
|
|
139
|
+
health: healthOf(a.health),
|
|
140
|
+
version: newest(a),
|
|
141
|
+
confidence: a.identity!.confidence,
|
|
142
|
+
evidence: a.identity!.evidence,
|
|
143
|
+
}));
|
|
144
|
+
}, [apps, app]);
|
|
145
|
+
|
|
146
|
+
// Position-preserving env switch: carry the selected workload + tab into the
|
|
147
|
+
// sibling when a matching workload exists there (exact kind+name, else the
|
|
148
|
+
// env-affix-stripped stem); otherwise land on the instance overview and say
|
|
149
|
+
// the workload wasn't found.
|
|
150
|
+
const switchInstance = useCallback(
|
|
151
|
+
(targetKey: string) => {
|
|
152
|
+
const target = apps.find((a) => a.key === targetKey);
|
|
153
|
+
const params = new URLSearchParams(searchParams);
|
|
154
|
+
params.set('app', targetKey);
|
|
155
|
+
const wk = params.get('workload');
|
|
156
|
+
let matched = false;
|
|
157
|
+
if (wk && target) {
|
|
158
|
+
// Stem matching strips this app group's own env tokens too, so
|
|
159
|
+
// discovered envs (loadtest, …) carry position like the trio does.
|
|
160
|
+
const identityEnvs = new Set((identityInstances ?? []).map((i) => i.env));
|
|
161
|
+
const m = matchWorkloadAcrossInstances(wk, target.workloads, identityEnvs);
|
|
162
|
+
if (m) {
|
|
163
|
+
params.set('workload', workloadKey(m));
|
|
164
|
+
matched = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!matched && wk) {
|
|
168
|
+
// A workload WAS selected but has no counterpart — land on the target
|
|
169
|
+
// instance's overview and say so. (With no workload selected the tab
|
|
170
|
+
// rides along: it applies to the lone workload either side.)
|
|
171
|
+
params.delete('workload');
|
|
172
|
+
params.delete('tab');
|
|
173
|
+
if (target) {
|
|
174
|
+
showToast(`No matching workload in ${target.identity?.env ?? target.name}`, { detail: 'Showing the instance overview instead.', type: 'info' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
setSearchParams(params);
|
|
178
|
+
},
|
|
179
|
+
[apps, identityInstances, searchParams, setSearchParams, showToast],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const discoveredEnvs = useMemo(
|
|
183
|
+
() => new Set(apps.map((a) => a.identity?.env).filter((e): e is string => !!e)),
|
|
184
|
+
[apps],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="flex-1 overflow-auto">
|
|
189
|
+
<ApplicationDetail
|
|
190
|
+
app={app}
|
|
191
|
+
onBack={onBack}
|
|
192
|
+
topology={topology}
|
|
193
|
+
topologyLoading={topologyLoading}
|
|
194
|
+
identityInstances={identityInstances}
|
|
195
|
+
onSwitchInstance={switchInstance}
|
|
196
|
+
discoveredEnvs={discoveredEnvs}
|
|
197
|
+
onNavigateToResource={onOpenResource}
|
|
198
|
+
selectedWorkloadKey={selectedWorkloadKey}
|
|
199
|
+
onSelectWorkload={selectWorkload}
|
|
200
|
+
renderWorkload={(workload: SelectedAppWorkload) => (
|
|
201
|
+
<div className="h-full overflow-hidden">
|
|
202
|
+
<WorkloadView
|
|
203
|
+
kind={kindToPlural(workload.kind)}
|
|
204
|
+
namespace={workload.namespace}
|
|
205
|
+
name={workload.name}
|
|
206
|
+
onBack={() => selectWorkload(null)}
|
|
207
|
+
// "Back" returns to the app graph — meaningless for a
|
|
208
|
+
// single-workload app, which has no graph to return to.
|
|
209
|
+
hideBackButton={(app.workloads?.length ?? 0) <= 1}
|
|
210
|
+
compactHeader
|
|
211
|
+
onNavigateToResource={onOpenResource}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
@@ -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
|
},
|
|
@@ -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
|
}
|
|
@@ -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}
|
|
@@ -5,6 +5,9 @@ import { clsx } from 'clsx'
|
|
|
5
5
|
import { Terminal } from 'lucide-react'
|
|
6
6
|
import {
|
|
7
7
|
WorkloadView as BaseWorkloadView,
|
|
8
|
+
EditableYamlView,
|
|
9
|
+
FetchResult,
|
|
10
|
+
type WorkloadTabType,
|
|
8
11
|
type RendererOverrides,
|
|
9
12
|
type GitOpsOwnerRef,
|
|
10
13
|
type GitOpsStatus,
|
|
@@ -57,7 +60,7 @@ import { useDesktopDownload } from '../../hooks/useDesktopDownload'
|
|
|
57
60
|
import { useCompareLauncher } from '../compare/useCompareLauncher'
|
|
58
61
|
import { apiVersionToGroup } from '../../utils/navigation'
|
|
59
62
|
|
|
60
|
-
type TabType =
|
|
63
|
+
type TabType = WorkloadTabType
|
|
61
64
|
|
|
62
65
|
// Stable reference — web renderer wrappers inject platform hooks internally
|
|
63
66
|
const rendererOverrides: RendererOverrides = {
|
|
@@ -136,6 +139,8 @@ interface WorkloadViewProps {
|
|
|
136
139
|
namespace: string
|
|
137
140
|
name: string
|
|
138
141
|
onBack: () => void
|
|
142
|
+
hideBackButton?: boolean
|
|
143
|
+
compactHeader?: boolean
|
|
139
144
|
onNavigateToResource?: NavigateToResource
|
|
140
145
|
onCollapseToDrawer?: () => void
|
|
141
146
|
expanded?: boolean
|
|
@@ -496,6 +501,7 @@ export function WorkloadView({
|
|
|
496
501
|
onTabChange={handleTabChange}
|
|
497
502
|
// Render props
|
|
498
503
|
renderLogsTab={(props) => <LogsTabContent {...props} />}
|
|
504
|
+
renderRelatedYaml={(ref) => <RelatedResourceYaml key={`${ref.kind}/${ref.namespace}/${ref.name}`} target={ref} />}
|
|
499
505
|
renderMetricsTab={({ kind, namespace: ns, name: n }) => (
|
|
500
506
|
<MetricsTabContent kind={kind} namespace={ns} name={n} resource={resource} expanded={expanded} />
|
|
501
507
|
)}
|
|
@@ -705,6 +711,12 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
|
|
|
705
711
|
return names
|
|
706
712
|
}, [resource])
|
|
707
713
|
|
|
714
|
+
// A terminated pod has nothing to follow — only stream live ones. Wait for
|
|
715
|
+
// the phase to be known so a completed pod isn't briefly streamed while the
|
|
716
|
+
// resource is still loading.
|
|
717
|
+
const phase = resource?.status?.phase
|
|
718
|
+
const autoStream = !!phase && phase !== 'Succeeded' && phase !== 'Failed'
|
|
719
|
+
|
|
708
720
|
useEffect(() => {
|
|
709
721
|
if (initialContainer && containers.includes(initialContainer)) {
|
|
710
722
|
onConsumeInitialContainer?.()
|
|
@@ -718,6 +730,7 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
|
|
|
718
730
|
podName={name}
|
|
719
731
|
containers={containers}
|
|
720
732
|
initialContainer={initialContainer || undefined}
|
|
733
|
+
autoStream={autoStream}
|
|
721
734
|
/>
|
|
722
735
|
</div>
|
|
723
736
|
)
|
|
@@ -742,6 +755,13 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
|
|
|
742
755
|
const { data: logsData } = usePodLogs(podNamespace, selectedPod || '', { tailLines: 1 })
|
|
743
756
|
const containers = logsData?.containers || []
|
|
744
757
|
|
|
758
|
+
// A terminated pod (common for Job/CronJob children) has nothing to follow —
|
|
759
|
+
// only stream live ones. Wait for the pod to load before deciding so we don't
|
|
760
|
+
// briefly auto-stream a completed pod while its phase is still unknown.
|
|
761
|
+
const { data: selectedPodResource } = useResource<any>('Pod', podNamespace, selectedPod || '')
|
|
762
|
+
const phase = selectedPodResource?.status?.phase
|
|
763
|
+
const autoStream = !!phase && phase !== 'Succeeded' && phase !== 'Failed'
|
|
764
|
+
|
|
745
765
|
if (pods.length === 0) {
|
|
746
766
|
return (
|
|
747
767
|
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
|
|
@@ -779,6 +799,7 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
|
|
|
779
799
|
podName={selectedPod}
|
|
780
800
|
containers={containers}
|
|
781
801
|
initialContainer={initialContainer || undefined}
|
|
802
|
+
autoStream={autoStream}
|
|
782
803
|
/>
|
|
783
804
|
</div>
|
|
784
805
|
)}
|
|
@@ -949,3 +970,26 @@ const FLUX_SOURCE_KIND_BY_LOWER = new Map<string, string>([
|
|
|
949
970
|
['ocirepository', 'OCIRepository'],
|
|
950
971
|
['bucket', 'Bucket'],
|
|
951
972
|
])
|
|
973
|
+
|
|
974
|
+
// Read-only manifest view for an object in the workload's neighborhood (the
|
|
975
|
+
// YAML tab's object rail). Read-only by design — editing an arbitrary related
|
|
976
|
+
// object belongs on that resource's own page.
|
|
977
|
+
function RelatedResourceYaml({ target }: { target: { kind: string; namespace: string; name: string; group?: string } }) {
|
|
978
|
+
const { data, isLoading, error } = useResource<any>(kindToPlural(target.kind), target.namespace, target.name, target.group)
|
|
979
|
+
const [copied, setCopied] = useState(false)
|
|
980
|
+
const handleCopy = useCallback((text: string) => {
|
|
981
|
+
navigator.clipboard.writeText(text)
|
|
982
|
+
setCopied(true)
|
|
983
|
+
setTimeout(() => setCopied(false), 1500)
|
|
984
|
+
}, [])
|
|
985
|
+
if (!data) return <FetchResult loading={isLoading} error={error as Error | null} className="h-32" />
|
|
986
|
+
return (
|
|
987
|
+
<EditableYamlView
|
|
988
|
+
resource={{ kind: kindToPlural(target.kind), namespace: target.namespace, name: target.name, group: target.group }}
|
|
989
|
+
data={data}
|
|
990
|
+
onCopy={handleCopy}
|
|
991
|
+
copied={copied}
|
|
992
|
+
readOnly
|
|
993
|
+
/>
|
|
994
|
+
)
|
|
995
|
+
}
|
|
@@ -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' }))
|