diff --git a/internal/hud/client/log_filter.go b/internal/hud/client/log_filter.go index 78ca80af88..29093b6301 100644 --- a/internal/hud/client/log_filter.go +++ b/internal/hud/client/log_filter.go @@ -109,16 +109,17 @@ func (f LogFilter) matchesSinceFilter(line logstore.LogLine) bool { // The implementation is identical to matchesFilter in web/src/OverviewLogPane.tsx. // except for term filtering as tools like grep can be used from the CLI. func (f LogFilter) Matches(line logstore.LogLine) bool { + // Check resource filter first - build events should also respect resource filtering + if !f.resources.Matches(line.ManifestName) { + return false + } + if line.BuildEvent != "" { - // Always leave in build event logs. + // Include build event logs that match the resource filter. // This makes it easier to see which logs belong to which builds. return true } - if !f.resources.Matches(line.ManifestName) { - return false - } - isBuild := isBuildSpanID(line.SpanID) if f.source == FilterSourceRuntime && isBuild { return false diff --git a/internal/hud/webview/convert.go b/internal/hud/webview/convert.go index 2cbb31e6dc..9f8920931f 100644 --- a/internal/hud/webview/convert.go +++ b/internal/hud/webview/convert.go @@ -436,6 +436,7 @@ func populateResourceInfoView(mt *store.ManifestTarget, r *v1alpha1.UIResource) AllContainersReady: store.AllPodContainersReady(pod), PodRestarts: kState.VisiblePodContainerRestarts(podID), DisplayNames: kState.EntityDisplayNames(), + Namespace: kState.GetNamespace(), } if podID != "" { rK8s.SpanID = string(k8sconv.SpanIDForPod(mt.Manifest.Name, podID)) diff --git a/internal/store/runtime_state.go b/internal/store/runtime_state.go index d80dc5e152..3b778e5b09 100644 --- a/internal/store/runtime_state.go +++ b/internal/store/runtime_state.go @@ -204,6 +204,20 @@ func (s K8sRuntimeState) EntityDisplayNames() []string { return k8s.UniqueNamesMeta(entities, 2) } +// GetNamespace returns the Kubernetes namespace for the deployed resources. +// It extracts the namespace from the first deployed reference if available. +func (s K8sRuntimeState) GetNamespace() string { + if s.ApplyFilter == nil || len(s.ApplyFilter.DeployedRefs) == 0 { + // Fall back to pod namespace if no deployed refs + pod := s.MostRecentPod() + if pod.Name != "" { + return pod.Namespace + } + return "" + } + return s.ApplyFilter.DeployedRefs[0].Namespace +} + type objectRefMeta struct { v1.ObjectReference } diff --git a/pkg/apis/core/v1alpha1/uiresource_types.go b/pkg/apis/core/v1alpha1/uiresource_types.go index 39176a0bc9..42abbb3df6 100644 --- a/pkg/apis/core/v1alpha1/uiresource_types.go +++ b/pkg/apis/core/v1alpha1/uiresource_types.go @@ -365,6 +365,11 @@ type UIResourceKubernetes struct { // for this resource. // +optional DisplayNames []string `json:"displayNames,omitempty" protobuf:"bytes,9,rep,name=displayNames"` + + // The Kubernetes namespace where resources are deployed. + // This allows grouping resources by namespace in the UI. + // +optional + Namespace string `json:"namespace,omitempty" protobuf:"bytes,10,opt,name=namespace"` } // UIResourceCompose contains status information specific to Docker Compose. diff --git a/web/src/OverviewTable.tsx b/web/src/OverviewTable.tsx index b2aad4135a..a34e31c028 100644 --- a/web/src/OverviewTable.tsx +++ b/web/src/OverviewTable.tsx @@ -35,8 +35,12 @@ import Features, { Flag, useFeatures } from "./feature" import { Hold } from "./Hold" import { getResourceLabels, + getResourceNamespace, + groupResourcesByNamespace, GroupByLabelView, + GroupByNamespaceView, orderLabels, + resourcesHaveNamespaces, TILTFILE_LABEL, UNLABELED_LABEL, } from "./labels" @@ -828,6 +832,97 @@ export function TableGroupedByLabels({ ) } +export function TableGroupedByNamespace({ + resources, + buttons, +}: TableWrapperProps) { + const features = useFeatures() + const logAlertIndex = useLogAlertIndex() + + // Group resources by namespace + const data = useMemo(() => { + const namespacesToResources: { [namespace: string]: RowValues[] } = {} + let ungrouped: RowValues[] = [] + + resources?.forEach((r) => { + const namespace = getResourceNamespace(r) + const isTiltfile = r.metadata?.name === ResourceName.tiltfile + const tableCell = uiResourceToCell(r, buttons, logAlertIndex) + + if (namespace) { + if (!namespacesToResources.hasOwnProperty(namespace)) { + namespacesToResources[namespace] = [] + } + namespacesToResources[namespace].push(tableCell) + } else if (isTiltfile) { + ungrouped.push(tableCell) + } else { + ungrouped.push(tableCell) + } + }) || [] + + const namespaces = Object.keys(namespacesToResources).sort() + return { namespaces, namespacesToResources, ungrouped } + }, [resources, buttons, logAlertIndex]) + + const totalOrder = useMemo(() => { + let totalOrder: RowValues[] = [] + data.namespaces.forEach((namespace) => + totalOrder.push(...enabledRowsFirst(data.namespacesToResources[namespace])) + ) + totalOrder.push(...enabledRowsFirst(data.ungrouped)) + return totalOrder + }, [data]) + + let [focused, setFocused] = useState("") + + // Global table settings are currently used to sort multiple + // tables by the same column + const [globalTableSettings, setGlobalTableSettings] = + useState>() + + const useControlledState = (state: TableState) => + useMemo(() => { + return { ...state, ...globalTableSettings } + }, [state, globalTableSettings]) + + const setGlobalSortBy = (columnId: string) => { + const sortBy = calculateNextSort(columnId, globalTableSettings?.sortBy) + setGlobalTableSettings({ sortBy }) + } + + return ( + <> + {data.namespaces.map((namespace) => ( + + ))} + {data.ungrouped.length > 0 && ( + + )} + + + ) +} + export function TableWithoutGroups({ resources, buttons }: TableWrapperProps) { const features = useFeatures() const logAlertIndex = useLogAlertIndex() @@ -862,6 +957,9 @@ function OverviewTableContent(props: OverviewTableProps) { const resourcesHaveLabels = props.view.uiResources?.some((r) => getResourceLabels(r).length > 0) || false + const resourcesHaveNamespaces = + props.view.uiResources?.some((r) => getResourceNamespace(r) !== undefined) || + false const { options } = useResourceListOptions() const resourceFilterApplied = options.resourceNameFilter.length > 0 @@ -877,6 +975,9 @@ function OverviewTableContent(props: OverviewTableProps) { // and no resource name filter is applied const displayResourceGroups = labelsEnabled && resourcesHaveLabels && !resourceFilterApplied + // Namespace groups are displayed when no label groups and resources have namespaces + const displayNamespaceGroups = + !resourceFilterApplied && !displayResourceGroups && resourcesHaveNamespaces if (displayResourceGroups) { return ( @@ -885,6 +986,13 @@ function OverviewTableContent(props: OverviewTableProps) { buttons={props.view.uiButtons} /> ) + } else if (displayNamespaceGroups) { + return ( + + ) } else { // The label group tip is only displayed if labels are enabled but not used const displayLabelGroupsTip = labelsEnabled && !resourcesHaveLabels diff --git a/web/src/SidebarItem.tsx b/web/src/SidebarItem.tsx index 7775a01987..9d9588418c 100644 --- a/web/src/SidebarItem.tsx +++ b/web/src/SidebarItem.tsx @@ -36,6 +36,7 @@ class SidebarItem { hold: Hold | null = null targetType: string stopBuildButton?: UIButton + resource: UIResource /** * Create a pared down SidebarItem from a ResourceView @@ -70,6 +71,7 @@ class SidebarItem { this.hold = status.waiting ? new Hold(status.waiting) : null this.targetType = resourceTargetType(res) this.stopBuildButton = stopBuildButton + this.resource = res } } diff --git a/web/src/SidebarResources.tsx b/web/src/SidebarResources.tsx index 0b61388c23..a79472f0b0 100644 --- a/web/src/SidebarResources.tsx +++ b/web/src/SidebarResources.tsx @@ -13,9 +13,13 @@ import { import { FeaturesContext, Flag, useFeatures } from "./feature" import { GroupByLabelView, + GroupByNamespaceView, orderLabels, TILTFILE_LABEL, UNLABELED_LABEL, + getResourceNamespace, + groupResourcesByNamespace, + resourcesHaveNamespaces, } from "./labels" import { OverviewSidebarOptions } from "./OverviewSidebarOptions" import PathBuilder from "./PathBuilder" @@ -40,7 +44,7 @@ import SidebarItemView, { import SidebarKeyboardShortcuts from "./SidebarKeyboardShortcuts" import { AnimDuration, Color, Font, FontSize, SizeUnit } from "./style-helpers" import { startBuild } from "./trigger" -import { ResourceName, ResourceStatus, ResourceView } from "./types" +import { ResourceName, ResourceStatus, ResourceView, UIResource } from "./types" import { useStarredResources } from "./StarredResourcesContext" export type SidebarProps = { @@ -387,6 +391,38 @@ function resourcesLabelView( return { labels, labelsToResources, tiltfile, unlabeled } } +function resourcesNamespaceView( + items: SidebarItem[] +): GroupByNamespaceView { + const namespacesToResources: { [key: string]: SidebarItem[] } = {} + const ungrouped: SidebarItem[] = [] + const namespacesSet = new Set() + + items.forEach((item) => { + const resource = item.resource + const namespace = getResourceNamespace(resource) + + if (namespace) { + if (!namespacesToResources[namespace]) { + namespacesToResources[namespace] = [] + } + namespacesToResources[namespace].push(item) + namespacesSet.add(namespace) + } else if (!item.isTiltfile) { + ungrouped.push(item) + } + }) + + // Sort namespaces alphabetically, with "default" first if present + const namespaces = Array.from(namespacesSet).sort((a, b) => { + if (a === "default") return -1 + if (b === "default") return 1 + return a.localeCompare(b) + }) + + return { namespaces, namespacesToResources, ungrouped } +} + function SidebarGroupedByLabels(props: SidebarGroupedByProps) { const { labels, labelsToResources, tiltfile, unlabeled } = resourcesLabelView( props.items @@ -434,6 +470,45 @@ function SidebarGroupedByLabels(props: SidebarGroupedByProps) { ) } +function SidebarGroupedByNamespace(props: SidebarGroupedByProps) { + const { namespaces, namespacesToResources, ungrouped } = resourcesNamespaceView( + props.items + ) + + // Build total order similar to label grouping + let totalOrder: SidebarItem[] = [] + namespaces.map((namespace) => { + totalOrder.push(...enabledItemsFirst(namespacesToResources[namespace])) + }) + totalOrder.push(...enabledItemsFirst(ungrouped)) + + return ( + <> + {namespaces.map((namespace) => ( + + ))} + {ungrouped.length > 0 && ( + + )} + + + ) +} + function hasAlerts(item: SidebarItem): boolean { return item.buildAlertCount > 0 || item.runtimeAlertCount > 0 } @@ -511,12 +586,18 @@ export class SidebarResources extends React.Component { const resourcesHaveLabels = this.props.items.some( (item) => item.labels.length > 0 ) + const resourcesHaveNamespaces = this.props.items.some( + (item) => getResourceNamespace(item.resource) !== undefined + ) // The label group tip is only displayed if labels are enabled but not used const displayLabelGroupsTip = labelsEnabled && !resourcesHaveLabels // The label group view does not display if a resource name filter is applied const displayLabelGroups = !resourceFilterApplied && labelsEnabled && resourcesHaveLabels + // The namespace group view displays if no label groups and resources have namespaces + const displayNamespaceGroups = + !resourceFilterApplied && !displayLabelGroups && resourcesHaveNamespaces return ( { items={filteredItems} onStartBuild={this.startBuildOnSelected} /> + ) : displayNamespaceGroups ? ( + ) : ( { )} {/* The label groups display handles the keyboard shortcuts separately. */} - {displayLabelGroups ? null : ( + {displayLabelGroups || displayNamespaceGroups ? null : ( ( return resources.some((r) => getLabels(r).length > 0) } + +// Namespace grouping support for Kubernetes resources +const DEFAULT_NAMESPACE = "default" +const UNGROUPED_NAMESPACE = "ungrouped" + +export type GroupByNamespaceView = { + namespaces: string[] + namespacesToResources: { [key: string]: T[] } + ungrouped: T[] +} + +/** + * Extract namespace from a K8s resource + * Resources with namespace info will be grouped by namespace + */ +export function getResourceNamespace(resource: UIResource): string | null { + const k8sInfo = resource.status?.k8sResourceInfo + if (!k8sInfo || !k8sInfo.namespace) { + return null + } + return k8sInfo.namespace +} + +/** + * Check if resources have namespace information + * This allows conditional grouping by namespace + */ +export function resourcesHaveNamespaces( + resources: T[] | undefined, + getNamespace: (resource: T) => string | null +): boolean { + if (resources === undefined) { + return false + } + + return resources.some((r) => getNamespace(r) !== null) +} + +/** + * Group resources by their Kubernetes namespace + * Resources without namespace info are placed in "ungrouped" + */ +export function groupResourcesByNamespace( + resources: T[], + getNamespace: (resource: T) => string | null +): GroupByNamespaceView { + const namespacesToResources: { [key: string]: T[] } = {} + const ungrouped: T[] = [] + const namespacesSet = new Set() + + resources.forEach((resource) => { + const namespace = getNamespace(resource) + if (namespace) { + if (!namespacesToResources[namespace]) { + namespacesToResources[namespace] = [] + } + namespacesToResources[namespace].push(resource) + namespacesSet.add(namespace) + } else { + ungrouped.push(resource) + } + }) + + // Sort namespaces alphabetically, with "default" first if present + const namespaces = Array.from(namespacesSet).sort((a, b) => { + if (a === DEFAULT_NAMESPACE) return -1 + if (b === DEFAULT_NAMESPACE) return 1 + return a.localeCompare(b) + }) + + return { + namespaces, + namespacesToResources, + ungrouped, + } +}