@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
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 =
|
|
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
|
+
}
|