@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.3.5",
3
+ "version": "1.4.1",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
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; resource clicks open the standard resource drawer. */}
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={navigateToResourceList}
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
- // Build a command that auto-retries connection after successful auth
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: `${authInfo.authCommand.command} && ${retryCmd}`,
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
- {authMe.authMode === 'proxy' ? (
71
- <p className="px-3 py-1.5 text-[11px] text-theme-text-tertiary">
72
- Session managed by auth proxy
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 sidecars.',
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 workload root-cause bundle: spec + resourceContext + current AND previous logs across all pods + warning events + startup-blocker analysis. For Pod/Deployment/StatefulSet/DaemonSet.',
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 daemonset' },
115
- { arg: 'namespace', required: true, desc: 'workload namespace' },
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 health.',
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 (Helm), L (labels), C (CRDs), A (Argo), F (Flux)' },
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 = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
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, getAuthHeaders, getCredentialsMode } from '../api/config'
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
- const response = await fetch(`${getApiBase()}/connection`, {
33
- credentials: getCredentialsMode(),
34
- headers: getAuthHeaders(),
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 fetch(`${getApiBase()}/connection/retry`, {
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' }))