@skyhook-io/radar-app 1.4.0 → 1.4.2
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 +18 -2
- package/src/api/client.ts +14 -0
- package/src/components/applications/ApplicationsView.tsx +218 -0
- package/src/components/audit/AuditSettingsDialog.tsx +30 -11
- package/src/components/audit/AuditView.tsx +8 -3
- package/src/components/settings/SettingsDialog.tsx +64 -23
- package/src/components/workload/WorkloadView.tsx +30 -1
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo } from 'react'
|
|
2
|
-
import { X, Plus, Trash2 } from 'lucide-react'
|
|
2
|
+
import { X, Plus, Trash2, Lock } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { useAuditSettings, useUpdateAuditSettings, useAudit } from '../../api/client'
|
|
4
|
+
import { useAuditSettings, useUpdateAuditSettings, useAudit, useCloudRole } from '../../api/client'
|
|
5
5
|
import type { CheckMeta } from '@skyhook-io/k8s-ui'
|
|
6
6
|
import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
|
|
7
7
|
|
|
@@ -14,6 +14,11 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
14
14
|
const { data: settings } = useAuditSettings()
|
|
15
15
|
const { data: auditData } = useAudit(namespaces)
|
|
16
16
|
const updateSettings = useUpdateAuditSettings()
|
|
17
|
+
// Audit policy is cluster-shared, so writes are owner-gated (enforced
|
|
18
|
+
// server-side too). Non-owners get a read-only view. Non-Cloud callers
|
|
19
|
+
// have no role and pass.
|
|
20
|
+
const { canAtLeast } = useCloudRole()
|
|
21
|
+
const canEdit = canAtLeast('owner')
|
|
17
22
|
const [ignoredNs, setIgnoredNs] = useState<string[]>([])
|
|
18
23
|
const [disabledChecks, setDisabledChecks] = useState<string[]>([])
|
|
19
24
|
const [newNs, setNewNs] = useState('')
|
|
@@ -75,6 +80,15 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
75
80
|
</div>
|
|
76
81
|
|
|
77
82
|
<div className="px-5 py-4 overflow-y-auto flex-1">
|
|
83
|
+
{!canEdit && (
|
|
84
|
+
<div className="mb-4 rounded-lg border border-theme-border bg-theme-elevated/50 p-3 flex items-start gap-2.5">
|
|
85
|
+
<Lock className="w-3.5 h-3.5 mt-0.5 shrink-0 text-theme-text-tertiary" />
|
|
86
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
87
|
+
Audit policy is shared across everyone using this Radar instance, so editing
|
|
88
|
+
is limited to owners. You can review the current settings here.
|
|
89
|
+
</p>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
78
92
|
{/* Ignored Namespaces */}
|
|
79
93
|
<div className="mb-6">
|
|
80
94
|
<label className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider">
|
|
@@ -90,7 +104,8 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
90
104
|
<span className="text-sm text-theme-text-primary">{ns}</span>
|
|
91
105
|
<button
|
|
92
106
|
onClick={() => setIgnoredNs(ignoredNs.filter(n => n !== ns))}
|
|
93
|
-
|
|
107
|
+
disabled={!canEdit}
|
|
108
|
+
className="p-1 rounded hover:bg-theme-hover text-theme-text-tertiary hover:text-red-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-theme-text-tertiary"
|
|
94
109
|
>
|
|
95
110
|
<Trash2 className="w-3.5 h-3.5" />
|
|
96
111
|
</button>
|
|
@@ -108,6 +123,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
108
123
|
onChange={e => setNewNs(e.target.value)}
|
|
109
124
|
onKeyDown={e => { if (e.key === 'Enter') addNamespace() }}
|
|
110
125
|
placeholder="Add namespace..."
|
|
126
|
+
disabled={!canEdit}
|
|
111
127
|
aria-invalid={newNsError ? true : undefined}
|
|
112
128
|
aria-describedby="new-ns-help"
|
|
113
129
|
className={clsx(
|
|
@@ -119,7 +135,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
119
135
|
/>
|
|
120
136
|
<button
|
|
121
137
|
onClick={addNamespace}
|
|
122
|
-
disabled={!canAddNamespace}
|
|
138
|
+
disabled={!canEdit || !canAddNamespace}
|
|
123
139
|
className="px-3 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
124
140
|
>
|
|
125
141
|
<Plus className="w-4 h-4" />
|
|
@@ -155,7 +171,8 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
155
171
|
type="checkbox"
|
|
156
172
|
checked={!disabled}
|
|
157
173
|
onChange={() => toggleCheck(check.id)}
|
|
158
|
-
|
|
174
|
+
disabled={!canEdit}
|
|
175
|
+
className="w-4 h-4 rounded border-theme-border text-skyhook-500 focus:ring-skyhook-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
159
176
|
/>
|
|
160
177
|
<div className="flex-1 min-w-0">
|
|
161
178
|
<span className="text-sm text-theme-text-primary">{check.title}</span>
|
|
@@ -181,14 +198,16 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
181
198
|
// text — otherwise the user clicks Save expecting their
|
|
182
199
|
// entry to be included and it's silently dropped.
|
|
183
200
|
disabled={
|
|
184
|
-
updateSettings.isPending || newNsError !== null || newNsDuplicate
|
|
201
|
+
!canEdit || updateSettings.isPending || newNsError !== null || newNsDuplicate
|
|
185
202
|
}
|
|
186
203
|
title={
|
|
187
|
-
|
|
188
|
-
? '
|
|
189
|
-
:
|
|
190
|
-
? '
|
|
191
|
-
:
|
|
204
|
+
!canEdit
|
|
205
|
+
? 'Audit settings can only be changed by owners'
|
|
206
|
+
: newNsError
|
|
207
|
+
? 'Fix or clear the pending namespace input before saving'
|
|
208
|
+
: newNsDuplicate
|
|
209
|
+
? 'Clear the duplicate pending input before saving'
|
|
210
|
+
: undefined
|
|
192
211
|
}
|
|
193
212
|
className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
194
213
|
>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react'
|
|
2
|
-
import { useAudit, useAuditSettings, useUpdateAuditSettings } from '../../api/client'
|
|
2
|
+
import { useAudit, useAuditSettings, useUpdateAuditSettings, useCloudRole } from '../../api/client'
|
|
3
3
|
import type { SelectedResource } from '../../types'
|
|
4
4
|
import { ChecksView, PaneLoader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
|
|
5
5
|
import { ArrowLeft, ClipboardCheck, Settings } from 'lucide-react'
|
|
@@ -21,6 +21,11 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
|
|
|
21
21
|
const { data, isLoading, error } = useAudit(namespaces)
|
|
22
22
|
const { data: auditSettings } = useAuditSettings()
|
|
23
23
|
const updateSettings = useUpdateAuditSettings()
|
|
24
|
+
// Audit policy is owner-gated (enforced server-side). Withhold the inline
|
|
25
|
+
// hide affordances from non-owners so they don't click into a 403 — the
|
|
26
|
+
// hide menus render only when these callbacks are passed.
|
|
27
|
+
const { canAtLeast } = useCloudRole()
|
|
28
|
+
const canEdit = canAtLeast('owner')
|
|
24
29
|
const [showSettings, setShowSettings] = useState(false)
|
|
25
30
|
|
|
26
31
|
const ignoredCount = auditSettings?.ignoredNamespaces?.length ?? 0
|
|
@@ -105,8 +110,8 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
|
|
|
105
110
|
catalog={data.checks ?? {}}
|
|
106
111
|
anyData
|
|
107
112
|
onResourceClick={onResourceClick}
|
|
108
|
-
onHideCheck={hideCheck}
|
|
109
|
-
onHideCategory={hideCategory}
|
|
113
|
+
onHideCheck={canEdit ? hideCheck : undefined}
|
|
114
|
+
onHideCategory={canEdit ? hideCategory : undefined}
|
|
110
115
|
/>
|
|
111
116
|
|
|
112
117
|
{showSettings && <AuditSettingsDialog namespaces={namespaces} onClose={() => setShowSettings(false)} />}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { Settings, X, RotateCcw, Loader2, Copy, Check, Pin, Shield } from 'lucide-react'
|
|
3
|
+
import { Settings, X, RotateCcw, Loader2, Copy, Check, Pin, Shield, Lock } from 'lucide-react'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
5
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
6
6
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
7
7
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
8
|
+
import { useCloudRole } from '../../api/client'
|
|
8
9
|
|
|
9
10
|
interface Config {
|
|
10
11
|
kubeconfig?: string
|
|
@@ -34,6 +35,13 @@ interface SettingsDialogProps {
|
|
|
34
35
|
export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsDialogProps) {
|
|
35
36
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
36
37
|
const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
|
|
38
|
+
// Radar configuration (kubeconfig, port, integrations…) is host-level and
|
|
39
|
+
// affects every user of this instance, so it's gated to owners. Personal
|
|
40
|
+
// sections (My permissions) stay visible to everyone. Non-Cloud callers
|
|
41
|
+
// (OSS, OIDC, kubectl plugin) have no role and pass — single-user laptops
|
|
42
|
+
// are never locked out of their own config. Backend enforces this too.
|
|
43
|
+
const { canAtLeast } = useCloudRole()
|
|
44
|
+
const canEditConfig = canAtLeast('owner')
|
|
37
45
|
const [configData, setConfigData] = useState<ConfigResponse | null>(null)
|
|
38
46
|
const [editedConfig, setEditedConfig] = useState<Config>({})
|
|
39
47
|
const [saving, setSaving] = useState(false)
|
|
@@ -169,33 +177,55 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
169
177
|
</div>
|
|
170
178
|
)}
|
|
171
179
|
{onShowMyPermissions && (
|
|
172
|
-
<div className="mb-
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
<div className="mb-5">
|
|
181
|
+
<SectionLabel>Personal</SectionLabel>
|
|
182
|
+
<div className="rounded-md border border-theme-border bg-theme-elevated/50 p-3">
|
|
183
|
+
<div className="flex items-center justify-between gap-3">
|
|
184
|
+
<div className="min-w-0">
|
|
185
|
+
<h3 className="text-sm font-medium text-theme-text-primary">My permissions</h3>
|
|
186
|
+
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
187
|
+
View what your current identity can do in this cluster.
|
|
188
|
+
</p>
|
|
189
|
+
</div>
|
|
190
|
+
<button
|
|
191
|
+
onClick={onShowMyPermissions}
|
|
192
|
+
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover rounded-md transition-colors"
|
|
193
|
+
>
|
|
194
|
+
<Shield className="w-3.5 h-3.5" />
|
|
195
|
+
Open
|
|
196
|
+
</button>
|
|
179
197
|
</div>
|
|
180
|
-
<button
|
|
181
|
-
onClick={onShowMyPermissions}
|
|
182
|
-
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover rounded-md transition-colors"
|
|
183
|
-
>
|
|
184
|
-
<Shield className="w-3.5 h-3.5" />
|
|
185
|
-
Open
|
|
186
|
-
</button>
|
|
187
198
|
</div>
|
|
188
199
|
</div>
|
|
189
200
|
)}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
201
|
+
|
|
202
|
+
<SectionLabel>Radar configuration</SectionLabel>
|
|
203
|
+
{canEditConfig ? (
|
|
204
|
+
<StartupConfigTab
|
|
205
|
+
config={editedConfig}
|
|
206
|
+
effectiveConfig={configData?.effective}
|
|
207
|
+
isDesktop={isDesktop}
|
|
208
|
+
onChange={updateConfigField}
|
|
209
|
+
/>
|
|
210
|
+
) : (
|
|
211
|
+
<div className="rounded-md border border-theme-border bg-theme-elevated/50 p-4 flex items-start gap-3">
|
|
212
|
+
<Lock className="w-4 h-4 mt-0.5 shrink-0 text-theme-text-tertiary" />
|
|
213
|
+
<div className="min-w-0">
|
|
214
|
+
<p className="text-sm font-medium text-theme-text-primary">Owner access required</p>
|
|
215
|
+
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
216
|
+
These settings (kubeconfig, server port, timeline, integrations) affect
|
|
217
|
+
every user of this Radar instance, so they're limited to owners. Ask an
|
|
218
|
+
owner if you need a change here.
|
|
219
|
+
</p>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
196
223
|
</div>
|
|
197
224
|
|
|
198
|
-
{/* Footer
|
|
225
|
+
{/* Footer — only the owner-gated config section is editable, so hide
|
|
226
|
+
the save controls entirely for non-owners (personal sections save
|
|
227
|
+
themselves). */}
|
|
228
|
+
{canEditConfig && (
|
|
199
229
|
<div className="flex items-center justify-between gap-3 p-4 border-t border-theme-border shrink-0">
|
|
200
230
|
<div className="flex items-center gap-2">
|
|
201
231
|
<button
|
|
@@ -225,12 +255,23 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
|
|
|
225
255
|
Save
|
|
226
256
|
</button>
|
|
227
257
|
</div>
|
|
258
|
+
)}
|
|
228
259
|
</div>
|
|
229
260
|
</div>,
|
|
230
261
|
document.body
|
|
231
262
|
)
|
|
232
263
|
}
|
|
233
264
|
|
|
265
|
+
// -- Section label ------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
function SectionLabel({ children }: { children: ReactNode }) {
|
|
268
|
+
return (
|
|
269
|
+
<h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-2">
|
|
270
|
+
{children}
|
|
271
|
+
</h3>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
234
275
|
// -- Startup Configuration Tab ------------------------------------------------
|
|
235
276
|
|
|
236
277
|
function StartupConfigTab({
|
|
@@ -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
|
+
}
|