diff --git a/internal/k8s/capabilities.go b/internal/k8s/capabilities.go index 3a73038da..bb1f71707 100644 --- a/internal/k8s/capabilities.go +++ b/internal/k8s/capabilities.go @@ -73,29 +73,37 @@ type PermissionCheckResult struct { // Capabilities represents the features available based on RBAC permissions type Capabilities struct { - Exec bool `json:"exec"` // Can create pods/exec (terminal feature) - LocalTerminal bool `json:"localTerminal"` // Local terminal available (not in-cluster, not disabled) - Logs bool `json:"logs"` // Can get pods/log (log viewer) - PortForward bool `json:"portForward"` // Can create pods/portforward - Secrets bool `json:"secrets"` // Can list secrets - SecretsUpdate bool `json:"secretsUpdate"` // Can update secrets (inline editing) - HelmWrite bool `json:"helmWrite"` // Helm write ops (detected via secrets/create as sentinel RBAC check) - NodeWrite bool `json:"nodeWrite"` // Can patch nodes (cordon/uncordon/drain) - MCPEnabled bool `json:"mcpEnabled"` // MCP server is running - Deployment DeploymentInfo `json:"deployment"` // How / where this Radar binary is running. Tells the UI which chrome to render or suppress (e.g. embedded mode hides the cluster headline + local-MCP card because the hub already renders both). - AuthEnabled bool `json:"authEnabled,omitempty"` // Auth is enabled on the server - Username string `json:"username,omitempty"` // Authenticated username (when auth enabled) - Resources *ResourcePermissions `json:"resources,omitempty"` // Per-resource-type permissions - Visibility *VisibilitySummary `json:"visibility,omitempty"` // Present when resource visibility is limited enough to make diagnostics incomplete + Exec bool `json:"exec"` // Can create pods/exec (terminal feature) + LocalTerminal bool `json:"localTerminal"` // Local terminal available (not in-cluster, not disabled) + Logs bool `json:"logs"` // Can get pods/log (log viewer) + PortForward bool `json:"portForward"` // Can create pods/portforward + Secrets bool `json:"secrets"` // Can list secrets + SecretsUpdate bool `json:"secretsUpdate"` // Can update secrets (inline editing) + HelmWrite bool `json:"helmWrite"` // Helm write ops (detected via secrets/create as sentinel RBAC check) + NodeWrite bool `json:"nodeWrite"` // Can patch nodes (cordon/uncordon/drain) + WorkloadWrites WorkloadWritePermissions `json:"workloadWrites"` // Can patch workload kinds (restart/scale controls) + MCPEnabled bool `json:"mcpEnabled"` // MCP server is running + Deployment DeploymentInfo `json:"deployment"` // How / where this Radar binary is running. Tells the UI which chrome to render or suppress (e.g. embedded mode hides the cluster headline + local-MCP card because the hub already renders both). + AuthEnabled bool `json:"authEnabled,omitempty"` // Auth is enabled on the server + Username string `json:"username,omitempty"` // Authenticated username (when auth enabled) + Resources *ResourcePermissions `json:"resources,omitempty"` // Per-resource-type permissions + Visibility *VisibilitySummary `json:"visibility,omitempty"` // Present when resource visibility is limited enough to make diagnostics incomplete } -// NamespaceCapabilities holds the effective exec/logs/portForward capabilities -// for a specific namespace. When global checks deny these capabilities, -// namespace-scoped RBAC re-checks may grant them. +// WorkloadWritePermissions indicates which workload resources the user can patch. +type WorkloadWritePermissions struct { + Deployments bool `json:"deployments"` + DaemonSets bool `json:"daemonSets"` + StatefulSets bool `json:"statefulSets"` + Rollouts bool `json:"rollouts"` +} + +// NamespaceCapabilities holds the effective capabilities for a specific namespace. type NamespaceCapabilities struct { - Exec bool `json:"exec"` - Logs bool `json:"logs"` - PortForward bool `json:"portForward"` + Exec bool `json:"exec"` + Logs bool `json:"logs"` + PortForward bool `json:"portForward"` + WorkloadWrites WorkloadWritePermissions `json:"workloadWrites"` } // DeploymentInfo describes how / where this Radar binary is running. @@ -208,20 +216,26 @@ func CheckCapabilities(ctx context.Context) (*Capabilities, error) { var hadErrors atomic.Bool type capCheck struct { - resource string - verb string - result *bool + group string + resource string + verb string + result *bool + namespaceFallback bool } caps := &Capabilities{} checks := []capCheck{ - {"pods/exec", "create", &caps.Exec}, - {"pods/log", "get", &caps.Logs}, - {"pods/portforward", "create", &caps.PortForward}, - {"secrets", "list", &caps.Secrets}, - {"secrets", "update", &caps.SecretsUpdate}, - {"secrets", "create", &caps.HelmWrite}, - {"nodes", "patch", &caps.NodeWrite}, + {resource: "pods/exec", verb: "create", result: &caps.Exec, namespaceFallback: true}, + {resource: "pods/log", verb: "get", result: &caps.Logs, namespaceFallback: true}, + {resource: "pods/portforward", verb: "create", result: &caps.PortForward, namespaceFallback: true}, + {resource: "secrets", verb: "list", result: &caps.Secrets, namespaceFallback: true}, + {resource: "secrets", verb: "update", result: &caps.SecretsUpdate, namespaceFallback: true}, + {resource: "secrets", verb: "create", result: &caps.HelmWrite, namespaceFallback: true}, + {resource: "nodes", verb: "patch", result: &caps.NodeWrite, namespaceFallback: true}, + {group: "apps", resource: "deployments", verb: "patch", result: &caps.WorkloadWrites.Deployments}, + {group: "apps", resource: "daemonsets", verb: "patch", result: &caps.WorkloadWrites.DaemonSets}, + {group: "apps", resource: "statefulsets", verb: "patch", result: &caps.WorkloadWrites.StatefulSets}, + {group: "argoproj.io", resource: "rollouts", verb: "patch", result: &caps.WorkloadWrites.Rollouts}, } var wg sync.WaitGroup @@ -230,13 +244,13 @@ func CheckCapabilities(ctx context.Context) (*Capabilities, error) { for _, check := range checks { go func(c capCheck) { defer wg.Done() - allowed, apiErr := canI(checkCtx, "", "", c.resource, c.verb) + allowed, apiErr := canI(checkCtx, "", c.group, c.resource, c.verb) if allowed { *c.result = true return } - if fallbackNs != "" { - allowed, nsApiErr := canI(checkCtx, fallbackNs, "", c.resource, c.verb) + if fallbackNs != "" && c.namespaceFallback { + allowed, nsApiErr := canI(checkCtx, fallbackNs, c.group, c.resource, c.verb) if allowed { *c.result = true return @@ -311,23 +325,13 @@ func InvalidateCapabilitiesCache() { nsCapMu.Unlock() } -// CheckNamespaceCapabilities performs namespace-scoped RBAC checks for capabilities -// that were denied by global checks (cluster-wide + effective-namespace fallback). -// This enables lazy re-checking when a user views a resource in a specific namespace — -// they may have namespace-scoped RoleBindings that grant exec/logs/portForward in -// namespaces other than the kubeconfig default. -// -// Returns nil if no namespace-scoped re-check is needed (all capabilities already allowed). -func CheckNamespaceCapabilities(ctx context.Context, namespace string, globalCaps *Capabilities) (*NamespaceCapabilities, error) { +// CheckNamespaceCapabilities performs namespace-scoped RBAC checks for +// capabilities that drive resource-level controls. +func CheckNamespaceCapabilities(ctx context.Context, namespace string) (*NamespaceCapabilities, error) { if namespace == "" { return nil, nil } - // If all three are already allowed globally, no need for namespace check - if globalCaps.Exec && globalCaps.Logs && globalCaps.PortForward { - return nil, nil - } - // Check namespace cache nsCapMu.RLock() if nsCapCache != nil { @@ -346,33 +350,27 @@ func CheckNamespaceCapabilities(ctx context.Context, namespace string, globalCap checkCtx, cancel := NewOperationContext(10 * time.Second) defer cancel() - result := &NamespaceCapabilities{ - Exec: globalCaps.Exec, - Logs: globalCaps.Logs, - PortForward: globalCaps.PortForward, - } + result := &NamespaceCapabilities{} - // Only re-check capabilities that were denied globally type capCheck struct { + group string resource string verb string result *bool } var checks []capCheck - if !globalCaps.Exec && !ForceDisableExec { - checks = append(checks, capCheck{"pods/exec", "create", &result.Exec}) - } - if !globalCaps.Logs { - checks = append(checks, capCheck{"pods/log", "get", &result.Logs}) - } - if !globalCaps.PortForward { - checks = append(checks, capCheck{"pods/portforward", "create", &result.PortForward}) - } - - if len(checks) == 0 { - return result, nil - } + if !ForceDisableExec { + checks = append(checks, capCheck{resource: "pods/exec", verb: "create", result: &result.Exec}) + } + checks = append(checks, + capCheck{resource: "pods/log", verb: "get", result: &result.Logs}, + capCheck{resource: "pods/portforward", verb: "create", result: &result.PortForward}, + capCheck{group: "apps", resource: "deployments", verb: "patch", result: &result.WorkloadWrites.Deployments}, + capCheck{group: "apps", resource: "daemonsets", verb: "patch", result: &result.WorkloadWrites.DaemonSets}, + capCheck{group: "apps", resource: "statefulsets", verb: "patch", result: &result.WorkloadWrites.StatefulSets}, + capCheck{group: "argoproj.io", resource: "rollouts", verb: "patch", result: &result.WorkloadWrites.Rollouts}, + ) var hadErrors atomic.Bool var wg sync.WaitGroup @@ -380,7 +378,7 @@ func CheckNamespaceCapabilities(ctx context.Context, namespace string, globalCap for _, check := range checks { go func(c capCheck) { defer wg.Done() - allowed, apiErr := canI(checkCtx, namespace, "", c.resource, c.verb) + allowed, apiErr := canI(checkCtx, namespace, c.group, c.resource, c.verb) if allowed { *c.result = true } @@ -448,20 +446,26 @@ func CheckCapabilitiesForUser(ctx context.Context, username string, groups []str defer cancel() type capCheck struct { - resource string - verb string - result *bool + group string + resource string + verb string + result *bool + namespaceFallback bool } caps := &Capabilities{} checks := []capCheck{ - {"pods/exec", "create", &caps.Exec}, - {"pods/log", "get", &caps.Logs}, - {"pods/portforward", "create", &caps.PortForward}, - {"secrets", "list", &caps.Secrets}, - {"secrets", "update", &caps.SecretsUpdate}, - {"secrets", "create", &caps.HelmWrite}, - {"nodes", "patch", &caps.NodeWrite}, + {resource: "pods/exec", verb: "create", result: &caps.Exec, namespaceFallback: true}, + {resource: "pods/log", verb: "get", result: &caps.Logs, namespaceFallback: true}, + {resource: "pods/portforward", verb: "create", result: &caps.PortForward, namespaceFallback: true}, + {resource: "secrets", verb: "list", result: &caps.Secrets, namespaceFallback: true}, + {resource: "secrets", verb: "update", result: &caps.SecretsUpdate, namespaceFallback: true}, + {resource: "secrets", verb: "create", result: &caps.HelmWrite, namespaceFallback: true}, + {resource: "nodes", verb: "patch", result: &caps.NodeWrite, namespaceFallback: true}, + {group: "apps", resource: "deployments", verb: "patch", result: &caps.WorkloadWrites.Deployments}, + {group: "apps", resource: "daemonsets", verb: "patch", result: &caps.WorkloadWrites.DaemonSets}, + {group: "apps", resource: "statefulsets", verb: "patch", result: &caps.WorkloadWrites.StatefulSets}, + {group: "argoproj.io", resource: "rollouts", verb: "patch", result: &caps.WorkloadWrites.Rollouts}, } var wg sync.WaitGroup @@ -470,14 +474,14 @@ func CheckCapabilitiesForUser(ctx context.Context, username string, groups []str for _, check := range checks { go func(c capCheck) { defer wg.Done() - allowed, _ := canIAs(checkCtx, k8sClient, username, groups, "", "", c.resource, c.verb) + allowed, _ := canIAs(checkCtx, k8sClient, username, groups, "", c.group, c.resource, c.verb) if allowed { *c.result = true return } // Try namespace-scoped fallback - if fallbackNs := GetEffectiveNamespace(); fallbackNs != "" { - allowed, _ = canIAs(checkCtx, k8sClient, username, groups, fallbackNs, "", c.resource, c.verb) + if fallbackNs := GetEffectiveNamespace(); fallbackNs != "" && c.namespaceFallback { + allowed, _ = canIAs(checkCtx, k8sClient, username, groups, fallbackNs, c.group, c.resource, c.verb) if allowed { *c.result = true } @@ -500,6 +504,63 @@ func CheckCapabilitiesForUser(ctx context.Context, username string, groups []str return caps, nil } +// CheckNamespaceCapabilitiesForUser runs namespace-scoped SubjectAccessReview +// checks as the authenticated user. +func CheckNamespaceCapabilitiesForUser(ctx context.Context, username string, groups []string, namespace string) (*NamespaceCapabilities, error) { + if namespace == "" { + return nil, nil + } + + k8sClient := GetClient() + if k8sClient == nil { + return nil, nil + } + + if GetConnectionStatus().State == StateDisconnected { + return nil, nil + } + + checkCtx, cancel := NewOperationContext(10 * time.Second) + defer cancel() + + result := &NamespaceCapabilities{} + + type capCheck struct { + group string + resource string + verb string + result *bool + } + + var checks []capCheck + if !ForceDisableExec { + checks = append(checks, capCheck{resource: "pods/exec", verb: "create", result: &result.Exec}) + } + checks = append(checks, + capCheck{resource: "pods/log", verb: "get", result: &result.Logs}, + capCheck{resource: "pods/portforward", verb: "create", result: &result.PortForward}, + capCheck{group: "apps", resource: "deployments", verb: "patch", result: &result.WorkloadWrites.Deployments}, + capCheck{group: "apps", resource: "daemonsets", verb: "patch", result: &result.WorkloadWrites.DaemonSets}, + capCheck{group: "apps", resource: "statefulsets", verb: "patch", result: &result.WorkloadWrites.StatefulSets}, + capCheck{group: "argoproj.io", resource: "rollouts", verb: "patch", result: &result.WorkloadWrites.Rollouts}, + ) + + var wg sync.WaitGroup + wg.Add(len(checks)) + for _, check := range checks { + go func(c capCheck) { + defer wg.Done() + allowed, _ := canIAs(checkCtx, k8sClient, username, groups, namespace, c.group, c.resource, c.verb) + if allowed { + *c.result = true + } + }(check) + } + wg.Wait() + + return result, nil +} + // canIAs checks if a specific user can perform an action using SubjectAccessReview. // Unlike canI which uses SelfSubjectAccessReview (checks the ServiceAccount), // this checks on behalf of a specific user. diff --git a/internal/server/server.go b/internal/server/server.go index a2c0ecfad..a43c28901 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -729,19 +729,23 @@ func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) { caps.Username = user.Username } - // Namespace-scoped re-check: when exec/logs/portForward are denied by the - // initial RBAC checks (cluster-wide + effective-namespace fallback), re-check - // scoped to the specific namespace the user is viewing. Users with - // namespace-scoped RoleBindings may have these permissions in namespaces - // other than the kubeconfig default. + // Namespace-scoped re-check for controls whose permission can differ by + // namespace. This keeps action visibility aligned with the namespace the + // user is viewing rather than only the kubeconfig default namespace. if ns := r.URL.Query().Get("namespace"); ns != "" { - nsCaps, err := k8s.CheckNamespaceCapabilities(r.Context(), ns, caps) + var nsCaps *k8s.NamespaceCapabilities + if user := auth.UserFromContext(r.Context()); user != nil { + nsCaps, err = k8s.CheckNamespaceCapabilitiesForUser(r.Context(), user.Username, user.Groups, ns) + } else { + nsCaps, err = k8s.CheckNamespaceCapabilities(r.Context(), ns) + } if err != nil { log.Printf("[capabilities] namespace-scoped check for %q failed: %v", ns, err) } else if nsCaps != nil { caps.Exec = nsCaps.Exec caps.Logs = nsCaps.Logs caps.PortForward = nsCaps.PortForward + caps.WorkloadWrites = nsCaps.WorkloadWrites } } diff --git a/internal/server/server_smoke_test.go b/internal/server/server_smoke_test.go index dfb1bb1aa..9f24ae232 100644 --- a/internal/server/server_smoke_test.go +++ b/internal/server/server_smoke_test.go @@ -1115,7 +1115,8 @@ func TestSmokeCapabilitiesShape(t *testing.T) { } var body struct { - Resources map[string]bool `json:"resources"` + Resources map[string]bool `json:"resources"` + WorkloadWrites map[string]bool `json:"workloadWrites"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) @@ -1141,6 +1142,22 @@ func TestSmokeCapabilitiesShape(t *testing.T) { tag, field.Name) } } + + if body.WorkloadWrites == nil { + t.Fatal("capabilities response missing 'workloadWrites' field") + } + workloadWritesType := reflect.TypeOf(k8s.WorkloadWritePermissions{}) + for i := 0; i < workloadWritesType.NumField(); i++ { + field := workloadWritesType.Field(i) + tag := field.Tag.Get("json") + if tag == "" { + t.Errorf("WorkloadWritePermissions.%s has no json tag", field.Name) + continue + } + if _, ok := body.WorkloadWrites[tag]; !ok { + t.Errorf("capabilities.workloadWrites missing key %q for WorkloadWritePermissions.%s", tag, field.Name) + } + } } // --- requireConnected guard (table-driven) --- diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index 87df0f2f6..d351a0640 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -25,6 +25,8 @@ import { GitCompare, Regex, ListChecks, + Minus, + Scale, } from 'lucide-react' import { clsx } from 'clsx' import { ResourceBar } from '../ui/ResourceBar' @@ -165,6 +167,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog' const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const const WORKLOAD_PROBLEMS = ['Unavailable', 'Rollout Stuck', 'Rollout In Progress'] as const const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets']) +const BULK_RESTART_WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'rollouts']) +const BULK_SCALE_WORKLOAD_KINDS = new Set(['deployments', 'statefulsets']) // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable) export const SKIP_FILTER_COLUMNS = new Set([ @@ -203,6 +207,8 @@ interface Column { minWidth?: number // minimum width in px } +type BulkResourceItem = { kind: string; group?: string; namespace: string; name: string } + /** * Extra column injected by the parent — for example, a leading "Cluster" * column when the table is rendered inside a multi-cluster host. @@ -1906,8 +1912,12 @@ interface ResourcesViewProps { */ onClearNamespaces?: () => void // Bulk operations - onBulkDelete?: (items: Array<{ kind: string; group?: string; namespace: string; name: string }>, options?: { force?: boolean; onSuccess?: () => void }) => void + onBulkDelete?: (items: BulkResourceItem[], options?: { force?: boolean; onSuccess?: () => void }) => void isBulkDeleting?: boolean + onBulkRestart?: (items: BulkResourceItem[], options?: { onSuccess?: () => void }) => void + isBulkRestarting?: boolean + onBulkScale?: (items: BulkResourceItem[], replicas: number, options?: { onSuccess?: () => void }) => void + isBulkScaling?: boolean } // Default selected kind @@ -2058,6 +2068,10 @@ export function ResourcesView({ onClearNamespaces, onBulkDelete, isBulkDeleting = false, + onBulkRestart, + isBulkRestarting = false, + onBulkScale, + isBulkScaling = false, }: ResourcesViewProps) { const initialFilters = getInitialFiltersFromURL() const [selectedKind, setSelectedKind] = useState(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch)) @@ -2077,6 +2091,10 @@ export function ResourcesView({ onSelectedKindChange?.(selectedKind) setBulkMode(false) setCheckedResources(new Set()) + setShowBulkDeleteConfirm(false) + setShowBulkRestartConfirm(false) + setShowBulkScaleDialog(false) + setBulkForceDelete(false) }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps const [searchTerm, setSearchTerm] = useState(initialFilters.search) const [regexMode, setRegexMode] = useState(false) @@ -2112,12 +2130,15 @@ export function ResourcesView({ const [ownerName, setOwnerName] = useState(initialFilters.ownerName) // Multi-select state for bulk operations. Checkboxes only render while - // bulk mode is active — entered via the toolbar toggle — so the risky - // bulk-delete surface stays out of the way during normal browsing. + // bulk mode is active — entered via the toolbar toggle — so mutating + // actions stay out of the way during normal browsing. const [bulkMode, setBulkMode] = useState(false) const [checkedResources, setCheckedResources] = useState>(new Set()) const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) + const [showBulkRestartConfirm, setShowBulkRestartConfirm] = useState(false) + const [showBulkScaleDialog, setShowBulkScaleDialog] = useState(false) const [bulkForceDelete, setBulkForceDelete] = useState(false) + const [bulkScaleReplicas, setBulkScaleReplicas] = useState(0) const exitBulkMode = useCallback(() => { setBulkMode(false) @@ -3652,13 +3673,44 @@ export function ResourcesView({ return filteredResources.filter(r => checkedResources.has(getResourceKey(r))) }, [filteredResources, checkedResources, getResourceKey]) + const checkedBulkItems = useMemo(() => { + return checkedItems.map(r => ({ + kind: selectedKind.name, + group: selectedKind.group, + namespace: r.metadata?.namespace || '', + name: r.metadata?.name || '', + })) + }, [checkedItems, selectedKind.name, selectedKind.group]) + + const checkedItemDetails = useMemo(() => { + return checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n') + }, [checkedItems]) + + const selectedKindName = selectedKind.name.toLowerCase() + const canBulkRestartSelectedKind = onBulkRestart != null && BULK_RESTART_WORKLOAD_KINDS.has(selectedKindName) + const canBulkScaleSelectedKind = onBulkScale != null && BULK_SCALE_WORKLOAD_KINDS.has(selectedKindName) + const canBulkSelect = onBulkDelete != null || canBulkRestartSelectedKind || canBulkScaleSelectedKind + const isBulkMutating = isBulkDeleting || isBulkRestarting || isBulkScaling + + const openBulkScaleDialog = useCallback(() => { + const replicas = checkedItems[0]?.spec?.replicas + setBulkScaleReplicas(typeof replicas === 'number' ? replicas : 0) + setShowBulkScaleDialog(true) + }, [checkedItems]) + + const commonBulkScaleReplicas = useMemo(() => { + if (checkedItems.length === 0) return null + const first = checkedItems[0]?.spec?.replicas ?? 0 + return checkedItems.every(r => (r.spec?.replicas ?? 0) === first) ? first : null + }, [checkedItems]) + const allVisibleChecked = filteredResources.length > 0 && checkedItems.length === filteredResources.length const toggleCheckAll = useCallback(() => { setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey))) }, [allVisibleChecked, filteredResources, getResourceKey]) - const isCheckboxMode = onBulkDelete != null && bulkMode + const isCheckboxMode = canBulkSelect && bulkMode // Filter columns by visibility const columns = useMemo(() => { @@ -4330,7 +4382,7 @@ export function ResourcesView({ )} - {onBulkDelete && ( + {canBulkSelect && ( + )} + {canBulkScaleSelectedKind && ( + + )} + {onBulkDelete && ( + + )} - + setBulkScaleReplicas(Math.min(10000, Math.max(0, Number.parseInt(e.target.value, 10) || 0)))} + className="w-24 text-center text-2xl font-semibold bg-theme-elevated border border-theme-border rounded-lg py-2 text-theme-text-primary focus:outline-none focus:border-skyhook-500" + autoFocus + /> + + +
+ {commonBulkScaleReplicas === null ? 'Current replicas vary across the selected workloads.' : `Current: ${commonBulkScaleReplicas} replicas`} +
+

+ All selected workloads will be set to the same replica count. Autoscalers may override it. +

+ + ) } diff --git a/packages/k8s-ui/src/types/core.ts b/packages/k8s-ui/src/types/core.ts index 72627eec7..c09196787 100644 --- a/packages/k8s-ui/src/types/core.ts +++ b/packages/k8s-ui/src/types/core.ts @@ -47,6 +47,15 @@ export const OPTIONAL_RESOURCE_KINDS: ReadonlyArray = 'verticalPodAutoscalers', ] +// Per-workload write permissions. Field names must match +// WorkloadWritePermissions in internal/k8s/capabilities.go. +export interface WorkloadWritePermissions { + deployments: boolean + daemonSets: boolean + statefulSets: boolean + rollouts: boolean +} + // Feature capabilities based on RBAC permissions export interface Capabilities { exec: boolean // Terminal feature (pods/exec) @@ -57,6 +66,7 @@ export interface Capabilities { secretsUpdate: boolean // Update secrets (inline editing) helmWrite: boolean // Helm write operations (install, upgrade, rollback, uninstall, apply values) nodeWrite: boolean // Node write operations (cordon, uncordon, drain) + workloadWrites: WorkloadWritePermissions // Workload patch permissions (restart/scale controls) mcpEnabled: boolean // MCP server is running // How / where this Radar binary is running. Optional on the wire so a // newer frontend (e.g. radar-hub-web bundling a fresher @skyhook-io/radar-app) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8317c2422..f5bdbb5f1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -717,11 +717,10 @@ export function useCapabilities() { }) } -// Namespace-scoped capabilities: lazy re-check for exec/logs/portForward when -// global RBAC checks denied them. Users with namespace-scoped RoleBindings may +// Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may // have these permissions in specific namespaces. -export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities) { - const needsCheck = namespace && (!globalCaps.exec || !globalCaps.logs || !globalCaps.portForward) +export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities | undefined) { + const needsCheck = namespace && globalCaps return useQuery({ queryKey: ['capabilities', namespace], queryFn: () => fetchJSON(`/capabilities?namespace=${encodeURIComponent(namespace!)}`), @@ -1782,6 +1781,82 @@ export function useBulkDeleteResources() { }) } +interface BulkWorkloadItem { + kind: string + namespace: string + name: string +} + +export function useBulkRestartWorkloads() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ items }: { items: BulkWorkloadItem[] }) => { + const results = await Promise.allSettled( + items.map(async ({ kind, namespace, name }) => { + const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, { + method: 'POST', + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error || `Failed to restart ${namespace}/${name}`) + } + return { kind, namespace, name } + }) + ) + const failed = results.filter(r => r.status === 'rejected') + if (failed.length > 0) { + throw new Error(`Failed to restart ${failed.length} of ${items.length} workloads`) + } + return { restarted: items.length } + }, + meta: { + errorMessage: 'Failed to restart some workloads', + successMessage: 'Workloads restarting', + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['resources'] }) + queryClient.invalidateQueries({ queryKey: ['topology'] }) + }, + }) +} + +export function useBulkScaleWorkloads() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ items, replicas }: { items: BulkWorkloadItem[]; replicas: number }) => { + const results = await Promise.allSettled( + items.map(async ({ kind, namespace, name }) => { + const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replicas }), + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error || `Failed to scale ${namespace}/${name}`) + } + return { kind, namespace, name } + }) + ) + const failed = results.filter(r => r.status === 'rejected') + if (failed.length > 0) { + throw new Error(`Failed to scale ${failed.length} of ${items.length} workloads`) + } + return { scaled: items.length, replicas } + }, + meta: { + errorMessage: 'Failed to scale some workloads', + successMessage: 'Workloads scaled', + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['resources'] }) + queryClient.invalidateQueries({ queryKey: ['topology'] }) + }, + }) +} + // Apply (create or update) a resource from YAML export interface ApplyResourceResult { name: string diff --git a/web/src/components/resources/ResourcesView.tsx b/web/src/components/resources/ResourcesView.tsx index bf3230f3e..4dd64e855 100644 --- a/web/src/components/resources/ResourcesView.tsx +++ b/web/src/components/resources/ResourcesView.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources } from '../../api/client' +import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads } from '../../api/client' import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config' import { useAPIResources } from '../../api/apiResources' import { initNavigationMap } from '@skyhook-io/k8s-ui' @@ -11,7 +11,7 @@ import { ResourcesView as BaseResourcesView, CORE_RESOURCES, } from '@skyhook-io/k8s-ui' -import type { ResourceQueryResult } from '@skyhook-io/k8s-ui' +import type { Capabilities, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui' import type { SelectedResource } from '../../types' import { kindToPlural, type NavigateToResource } from '../../utils/navigation' import { CreateResourceDialog } from '../shared/CreateResourceDialog' @@ -31,10 +31,60 @@ interface ResourcesViewProps { onClearNamespaces?: () => void } +type SelectedKindInfo = { name: string; kind: string; group: string } | null + +function canBulkRestartKind(kind: SelectedKindInfo, writes: WorkloadWritePermissions | undefined): boolean { + switch (kind?.name.toLowerCase()) { + case 'deployments': + return writes?.deployments === true + case 'daemonsets': + return writes?.daemonSets === true + case 'statefulsets': + return writes?.statefulSets === true + case 'rollouts': + return kind.group === 'argoproj.io' && writes?.rollouts === true + default: + return false + } +} + +function canBulkScaleKind(kind: SelectedKindInfo, writes: WorkloadWritePermissions | undefined): boolean { + switch (kind?.name.toLowerCase()) { + case 'deployments': + return writes?.deployments === true + case 'statefulsets': + return writes?.statefulSets === true + default: + return false + } +} + +function intersectWorkloadWrites(capabilities: Capabilities[] | undefined): WorkloadWritePermissions | undefined { + if (!capabilities || capabilities.length === 0) return undefined + return { + deployments: capabilities.every(c => c.workloadWrites?.deployments === true), + daemonSets: capabilities.every(c => c.workloadWrites?.daemonSets === true), + statefulSets: capabilities.every(c => c.workloadWrites?.statefulSets === true), + rollouts: capabilities.every(c => c.workloadWrites?.rollouts === true), + } +} + export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) { const location = useLocation() const navigate = useNavigate() + const { data: capabilities } = useCapabilities() + const namespaceForCapabilities = namespaces.length === 1 ? namespaces[0] : undefined + const { data: namespaceCapabilities } = useNamespaceCapabilities(namespaceForCapabilities, capabilities) + const namespaceCapabilityNames = useMemo(() => namespaces.length > 1 ? [...namespaces].sort() : [], [namespaces]) + const { data: namespaceCapabilitiesList } = useQuery({ + queryKey: ['capabilities', 'namespaces', namespaceCapabilityNames], + queryFn: () => Promise.all(namespaceCapabilityNames.map(ns => fetchJSON(`/capabilities?namespace=${encodeURIComponent(ns)}`))), + enabled: namespaceCapabilityNames.length > 1 && capabilities != null, + staleTime: 60000, + }) + const multiNamespaceWorkloadWrites = useMemo(() => intersectWorkloadWrites(namespaceCapabilitiesList), [namespaceCapabilitiesList]) + // API resources discovery const { data: apiResources } = useAPIResources() @@ -44,7 +94,14 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o }, [apiResources]) // Track the selected kind from the k8s-ui component - const [selectedKind, setSelectedKind] = useState<{ name: string; kind: string; group: string } | null>(null) + const [selectedKind, setSelectedKind] = useState(null) + const workloadWrites = namespaces.length === 0 + ? capabilities?.workloadWrites + : namespaces.length === 1 + ? namespaceCapabilities?.workloadWrites + : multiNamespaceWorkloadWrites + const canBulkRestartSelectedKind = useMemo(() => canBulkRestartKind(selectedKind, workloadWrites), [selectedKind, workloadWrites]) + const canBulkScaleSelectedKind = useMemo(() => canBulkScaleKind(selectedKind, workloadWrites), [selectedKind, workloadWrites]) // Lightweight resource counts for sidebar badges (~2KB instead of ~608MB) const namespacesParam = namespaces.join(',') @@ -148,6 +205,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o // Bulk delete const bulkDeleteMutation = useBulkDeleteResources() + const bulkRestartMutation = useBulkRestartWorkloads() + const bulkScaleMutation = useBulkScaleWorkloads() // Navigation adapter. k8s-ui constructs paths from `basePath` (which // includes the router basename so they line up with window.location.pathname @@ -230,6 +289,10 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o // Bulk operations onBulkDelete={(items, options) => bulkDeleteMutation.mutate({ items, force: options?.force }, { onSuccess: options?.onSuccess })} isBulkDeleting={bulkDeleteMutation.isPending} + onBulkRestart={canBulkRestartSelectedKind ? (items, options) => bulkRestartMutation.mutate({ items }, { onSuccess: options?.onSuccess }) : undefined} + isBulkRestarting={canBulkRestartSelectedKind && bulkRestartMutation.isPending} + onBulkScale={canBulkScaleSelectedKind ? (items, replicas, options) => bulkScaleMutation.mutate({ items, replicas }, { onSuccess: options?.onSuccess }) : undefined} + isBulkScaling={canBulkScaleSelectedKind && bulkScaleMutation.isPending} /> !allowed && !isOptionalKind(kind)) } -// Namespace-scoped capability hooks: lazily re-check exec/logs/portForward -// scoped to a specific namespace when global RBAC checks denied them. -// Falls back to global capability values while the namespace check is loading -// or when all capabilities are already granted. +// Namespace-scoped capability hooks. Falls back to global capability values +// while the namespace check is loading. export function useNamespacedCapabilities(namespace: string | undefined) { const globalCaps = useContext(CapabilitiesContext) const { data: nsCaps, error } = useNamespaceCapabilities(namespace, globalCaps) @@ -137,5 +147,6 @@ export function useNamespacedCapabilities(namespace: string | undefined) { canExec: nsCaps?.exec ?? globalCaps.exec, canViewLogs: nsCaps?.logs ?? globalCaps.logs, canPortForward: nsCaps?.portForward ?? globalCaps.portForward, - }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, nsCaps]) + workloadWrites: nsCaps?.workloadWrites ?? globalCaps.workloadWrites, + }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, nsCaps]) }