@skyhook-io/radar-app 1.4.0 → 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.4.0",
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
@@ -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'
@@ -451,7 +453,7 @@ function AppInner() {
451
453
  const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
452
454
 
453
455
  // View switching keyboard shortcuts
454
- 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']
455
457
  useRegisterShortcuts([
456
458
  ...views.map((view, i) => ({
457
459
  id: `view-${view}`,
@@ -1207,6 +1209,10 @@ function AppInner() {
1207
1209
  { view: 'timeline' as const, icon: Clock, label: 'Timeline' },
1208
1210
  { view: 'helm' as const, icon: Package, label: 'Helm' },
1209
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.
1210
1216
  { view: 'traffic' as const, icon: Activity, label: 'Traffic' },
1211
1217
  // Cost is intentionally hidden from the pill bar for now — the view still
1212
1218
  // exists and is reachable via /cost, the Home dashboard card, and the
@@ -1654,6 +1660,16 @@ function AppInner() {
1654
1660
  />
1655
1661
  )}
1656
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
+
1657
1673
  {/* Traffic view */}
1658
1674
  {mainView === 'traffic' && (
1659
1675
  <TrafficView namespaces={namespaces} />
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'
@@ -853,6 +854,19 @@ export function useTopology(namespaces: string[], viewMode: string = 'resources'
853
854
  })
854
855
  }
855
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
+
856
870
  export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
857
871
  const ns = namespace || '_'
858
872
  const params = new URLSearchParams()
@@ -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
+ }
@@ -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
  )}
@@ -964,3 +970,26 @@ const FLUX_SOURCE_KIND_BY_LOWER = new Map<string, string>([
964
970
  ['ocirepository', 'OCIRepository'],
965
971
  ['bucket', 'Bucket'],
966
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
+ }