diff --git a/.cursor/rules/ecto-queries-in-schema.mdc b/.cursor/rules/ecto-queries-in-schema.mdc new file mode 100644 index 0000000000..8227a1d182 --- /dev/null +++ b/.cursor/rules/ecto-queries-in-schema.mdc @@ -0,0 +1,11 @@ +--- +description: Keep Ecto queries inside schema modules +globs: "**/*.{ex,exs}" +alwaysApply: false +--- + +# Ecto Queries + +There should never be queries outside of schema modules (`lib/console/schema`). + +Define `from`/`where`/`join` queries on the relevant `Console.Schema.*` module. Resolvers and contexts only compose those functions and call `Repo.all/1`. diff --git a/.cursor/rules/graphql-dataloaders.mdc b/.cursor/rules/graphql-dataloaders.mdc new file mode 100644 index 0000000000..419eb474a7 --- /dev/null +++ b/.cursor/rules/graphql-dataloaders.mdc @@ -0,0 +1,11 @@ +--- +description: Use Absinthe dataloaders for batched GraphQL fields +globs: lib/console/graphql/**/*.ex +alwaysApply: false +--- + +# GraphQL Dataloaders + +Use Absinthe dataloaders for batched per-parent fields. Do not use `Absinthe.Resolution.Helpers.batch/3`. + +Add a `Dataloader.KV` source in `lib/console/graphql/resolvers/dataloader.ex`, register it in `Console.GraphQl`, and resolve with `manual_dataloader` or the loader's `resolve` helper. diff --git a/AGENTS.md b/AGENTS.md index 17db6c6fe8..0a8e67e6c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,8 @@ 3. Never use a nested case when a with expression is possible. 4. Defer to ecto for input validation. You should rarely need to use put_change, trust the builtins. 5. Avoid usage of `if` and `cond` if a more elegant case expression is possible. +6. There should never be queries outside of schema modules. +7. Use Absinthe dataloaders for batched GraphQL fields, not `batch/3`. ## Broad go guidance diff --git a/go/client/models_gen.go b/go/client/models_gen.go index 6a23ee845a..3c3d4e4d0a 100644 --- a/go/client/models_gen.go +++ b/go/client/models_gen.go @@ -2973,6 +2973,11 @@ type ComponentContentAttributes struct { Live *string `json:"live,omitempty"` } +type ComponentStatusCount struct { + State ComponentState `json:"state"` + Count int64 `json:"count"` +} + // A tree view of the kubernetes object hierarchy beneath a component type ComponentTree struct { Root *KubernetesUnstructured `json:"root,omitempty"` @@ -3769,7 +3774,21 @@ type Flow struct { // write policy for this flow WriteBindings []*PolicyBinding `json:"writeBindings,omitempty"` // the project this flow belongs to - Project *Project `json:"project,omitempty"` + Project *Project `json:"project,omitempty"` + // the number of services in this flow + ServiceCount *int64 `json:"serviceCount,omitempty"` + // the number of service components in this flow + ComponentCount *int64 `json:"componentCount,omitempty"` + // the number of alerts for services in this flow + AlertCount *int64 `json:"alertCount,omitempty"` + // the number of pipelines in this flow + PipelineCount *int64 `json:"pipelineCount,omitempty"` + // the number of pending pipeline gates in this flow + PendingPipelineCount *int64 `json:"pendingPipelineCount,omitempty"` + // a rollup of service statuses in this flow + ServiceStatuses []*ServiceStatusCount `json:"serviceStatuses,omitempty"` + // a rollup of component states in this flow + ComponentStatuses []*ComponentStatusCount `json:"componentStatuses,omitempty"` Services *ServiceDeploymentConnection `json:"services,omitempty"` Pipelines *PipelineConnection `json:"pipelines,omitempty"` PullRequests *PullRequestConnection `json:"pullRequests,omitempty"` @@ -4130,9 +4149,11 @@ type Group struct { Name string `json:"name"` Description *string `json:"description,omitempty"` // automatically adds all users in the system to this group - Global *bool `json:"global,omitempty"` - InsertedAt *string `json:"insertedAt,omitempty"` - UpdatedAt *string `json:"updatedAt,omitempty"` + Global *bool `json:"global,omitempty"` + // number of users in this group + MemberCount *int64 `json:"memberCount,omitempty"` + InsertedAt *string `json:"insertedAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` } type GroupAttributes struct { @@ -14724,6 +14745,63 @@ func (e EvidenceType) MarshalJSON() ([]byte, error) { return buf.Bytes(), nil } +type FlowSort string + +const ( + FlowSortName FlowSort = "NAME" + FlowSortServiceCount FlowSort = "SERVICE_COUNT" + FlowSortFavorited FlowSort = "FAVORITED" +) + +var AllFlowSort = []FlowSort{ + FlowSortName, + FlowSortServiceCount, + FlowSortFavorited, +} + +func (e FlowSort) IsValid() bool { + switch e { + case FlowSortName, FlowSortServiceCount, FlowSortFavorited: + return true + } + return false +} + +func (e FlowSort) String() string { + return string(e) +} + +func (e *FlowSort) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = FlowSort(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid FlowSort", str) + } + return nil +} + +func (e FlowSort) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *FlowSort) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e FlowSort) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + type GateState string const ( @@ -15303,61 +15381,6 @@ func (e IssueSort) MarshalJSON() ([]byte, error) { return buf.Bytes(), nil } -type IssueSortDirection string - -const ( - IssueSortDirectionAsc IssueSortDirection = "ASC" - IssueSortDirectionDesc IssueSortDirection = "DESC" -) - -var AllIssueSortDirection = []IssueSortDirection{ - IssueSortDirectionAsc, - IssueSortDirectionDesc, -} - -func (e IssueSortDirection) IsValid() bool { - switch e { - case IssueSortDirectionAsc, IssueSortDirectionDesc: - return true - } - return false -} - -func (e IssueSortDirection) String() string { - return string(e) -} - -func (e *IssueSortDirection) UnmarshalGQL(v any) error { - str, ok := v.(string) - if !ok { - return fmt.Errorf("enums must be strings") - } - - *e = IssueSortDirection(str) - if !e.IsValid() { - return fmt.Errorf("%s is not a valid IssueSortDirection", str) - } - return nil -} - -func (e IssueSortDirection) MarshalGQL(w io.Writer) { - fmt.Fprint(w, strconv.Quote(e.String())) -} - -func (e *IssueSortDirection) UnmarshalJSON(b []byte) error { - s, err := strconv.Unquote(string(b)) - if err != nil { - return err - } - return e.UnmarshalGQL(s) -} - -func (e IssueSortDirection) MarshalJSON() ([]byte, error) { - var buf bytes.Buffer - e.MarshalGQL(&buf) - return buf.Bytes(), nil -} - type IssueStatus string const ( @@ -18246,6 +18269,61 @@ func (e SinkType) MarshalJSON() ([]byte, error) { return buf.Bytes(), nil } +type SortDirection string + +const ( + SortDirectionAsc SortDirection = "ASC" + SortDirectionDesc SortDirection = "DESC" +) + +var AllSortDirection = []SortDirection{ + SortDirectionAsc, + SortDirectionDesc, +} + +func (e SortDirection) IsValid() bool { + switch e { + case SortDirectionAsc, SortDirectionDesc: + return true + } + return false +} + +func (e SortDirection) String() string { + return string(e) +} + +func (e *SortDirection) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = SortDirection(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid SortDirection", str) + } + return nil +} + +func (e SortDirection) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *SortDirection) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e SortDirection) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + type StackStatus string const ( diff --git a/js/console/src/components/flows/FlowActionsMenu.tsx b/js/console/src/components/flows/FlowActionsMenu.tsx new file mode 100644 index 0000000000..a34f8b68fe --- /dev/null +++ b/js/console/src/components/flows/FlowActionsMenu.tsx @@ -0,0 +1,110 @@ +import { GitPullIcon, ListBoxItem, PeopleIcon } from '@pluralsh/design-system' +import { + PermissionsIdType, + PermissionsModal, +} from 'components/cd/utils/PermissionsModal' +import { useLogin } from 'components/contexts' +import { FlowFavoriteStar } from 'components/flows/FlowFavoriteButton' +import { flowTabPath } from 'components/flows/flowHealth' +import { MoreMenu } from 'components/utils/MoreMenu' +import { hasAccess } from 'components/utils/persona' +import { FlowBasicWithBindingsFragment } from 'generated/graphql' +import { MouseEvent, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +function stopBubble(event: { stopPropagation: () => void }) { + event.stopPropagation() +} + +function stopLink(event: MouseEvent) { + event.preventDefault() + event.stopPropagation() +} + +export function FlowActionsMenu({ + flow, + search, + refetch, + favorited, + onToggleFavorite, +}: { + flow: FlowBasicWithBindingsFragment + search: string + refetch: () => void + favorited: boolean + onToggleFavorite: () => void +}) { + const { personaConfiguration } = useLogin() + const navigate = useNavigate() + const showPermissionsBtn = hasAccess( + personaConfiguration, + 'flows.permissions' + ) + const showPipelines = hasAccess(personaConfiguration, 'flows.pipelines') + const [menuKey, setMenuKey] = useState('') + const favoriteLabel = favorited + ? 'Unfavorite this flow' + : 'Favorite this flow' + const onSelect = { + pipelines: () => navigate(flowTabPath(flow.name, 'pipelines', search)), + favorite: onToggleFavorite, + } + + return ( + + { + const action = onSelect[key as keyof typeof onSelect] + + if (action) action() + else setMenuKey(key) + }} + > + {showPermissionsBtn && ( + } + textValue="Update permission" + /> + )} + {showPipelines && ( + } + textValue="View pipelines" + /> + )} + } + textValue={favoriteLabel} + /> + + {showPermissionsBtn && ( + setMenuKey('')} + /> + )} + + ) +} + +const ActionsSC = styled.div({ + position: 'relative', + zIndex: 1, + pointerEvents: 'auto', +}) diff --git a/js/console/src/components/flows/FlowCard.tsx b/js/console/src/components/flows/FlowCard.tsx index 25957f78ec..5a5a9d3ff6 100644 --- a/js/console/src/components/flows/FlowCard.tsx +++ b/js/console/src/components/flows/FlowCard.tsx @@ -1,124 +1,187 @@ import { AppIcon, - ArrowRightIcon, Card, - Chip, + CaretRightIcon, Flex, FlowIcon, - GitPullIcon, - IconFrame, - PeopleIcon, } from '@pluralsh/design-system' +import { FlowActionsMenu } from 'components/flows/FlowActionsMenu' +import { FlowFavoriteButton } from 'components/flows/FlowFavoriteButton' import { - PermissionsIdType, - PermissionsModal, -} from 'components/cd/utils/PermissionsModal' -import { useLogin } from 'components/contexts' -import { Body1BoldP, Body2P } from 'components/utils/typography/Text' -import { hasAccess } from 'components/utils/persona' + FlowAlertChip, + FlowHealthChips, + FlowPipelineChip, + FlowTab, + HealthBucket, + componentHealthCounts, + flowTabPath, +} from 'components/flows/flowHealth' +import { Body1BoldP, Body2P, CaptionP } from 'components/utils/typography/Text' import { FlowBasicWithBindingsFragment } from 'generated/graphql' -import pluralize from 'pluralize' -import { useState } from 'react' -import { Link, useLocation, useNavigate } from 'react-router-dom' -import { getFlowDetailsPath } from 'routes/flowRoutesConsts' +import { Link, useLocation } from 'react-router-dom' import styled from 'styled-components' +const LINE_CLAMP = { + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +} as const + export function FlowCard({ flow, refetch, + favorited, + onToggleFavorite, }: { flow: FlowBasicWithBindingsFragment refetch: () => void + favorited: boolean + onToggleFavorite: () => void }) { const { search } = useLocation() - const navigate = useNavigate() - const { personaConfiguration } = useLogin() - const showPermissionsBtn = hasAccess( - personaConfiguration, - 'flows.permissions' - ) - const showPipelines = hasAccess(personaConfiguration, 'flows.pipelines') - const [hovered, setHovered] = useState(false) - const [showPermissions, setShowPermissions] = useState(false) - const numAlerts = flow.alerts?.edges?.length ?? 0 - const flowPath = getFlowDetailsPath({ flowIdOrName: flow.name }) + const tab = (name: FlowTab, component?: HealthBucket) => + flowTabPath(flow.name, name, search, component) + return ( - <> - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - - + + + + } + /> + + {flow.name} + + + + + Components {flow.componentCount ?? 0} + + + Services {flow.serviceCount ?? 0} + + + {flow.description && ( + - } + {flow.description} + + )} + + + + Components + + tab('services', bucket)} /> - {flow.name} - - {flow.description} - - {numAlerts} - {pluralize(' alert', numAlerts)} - - - - - {showPermissionsBtn && ( - { - e.preventDefault() - e.stopPropagation() - setShowPermissions(!showPermissions) - }} - icon={} - /> - )} - {showPipelines && ( - { - e.preventDefault() - e.stopPropagation() - navigate(`${flowPath}/pipelines${search}`) - }} - icon={} - /> - )} - - - - - - {showPermissionsBtn && ( - setShowPermissions(false)} + + + + Alerts + + + + + + Pipelines + + + + + + + + + + + - )} - + + ) } + +const CardAppIconSC = styled(AppIcon)({ + '& img, & svg': { + width: 20, + height: 20, + }, +}) + +const HeaderSC = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing.small, + width: '100%', +})) + +const MetaSC = styled.div(({ theme }) => ({ + ...theme.partials.text.caption, + color: theme.colors['text-xlight'], + display: 'flex', + gap: theme.spacing.xsmall, +})) + +const MetaLabelSC = styled.span(({ theme }) => ({ + color: theme.colors['text-input-disabled'], +})) + +const MetricsSC = styled.div(({ theme }) => ({ + display: 'flex', + gap: theme.spacing.medium, + width: '100%', + flexWrap: 'wrap', +})) + +const MetricGroupSC = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing.xxsmall, + minWidth: 0, +})) + const ContentSC = styled.div(({ theme }) => ({ display: 'flex', flexDirection: 'column', @@ -127,31 +190,38 @@ const ContentSC = styled.div(({ theme }) => ({ gap: theme.spacing.small, })) -const FooterSC = styled.div<{ $parentHover: boolean }>( - ({ $parentHover, theme }) => ({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: `${theme.spacing.small}px ${theme.spacing.medium}px`, - borderTop: theme.borders.default, - ...($parentHover && { - '&:not(:has(button:hover))': { - backgroundColor: theme.colors['fill-one-hover'], - borderTopColor: theme.colors['border-fill-one'], - }, - }), - }) -) +const FooterSC = styled.div(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: `${theme.spacing.small}px ${theme.spacing.medium}px`, + borderTop: theme.borders.default, +})) + +const CardLinkSC = styled(Link)({ + position: 'absolute', + inset: 0, + zIndex: 0, +}) const CardSC = styled(Card)(({ theme }) => ({ + position: 'relative', display: 'flex', flexDirection: 'column', width: '100%', overflow: 'hidden', cursor: 'pointer', textDecoration: 'none', + '& button': { + position: 'relative', + zIndex: 1, + }, '&:hover:not(:has(button:hover))': { backgroundColor: theme.colors['fill-one-hover'], borderColor: theme.colors['border-fill-one'], + [`${FooterSC}`]: { + backgroundColor: theme.colors['fill-one-hover'], + borderTopColor: theme.colors['border-fill-one'], + }, }, })) diff --git a/js/console/src/components/flows/FlowFavoriteButton.tsx b/js/console/src/components/flows/FlowFavoriteButton.tsx new file mode 100644 index 0000000000..82d264a5b8 --- /dev/null +++ b/js/console/src/components/flows/FlowFavoriteButton.tsx @@ -0,0 +1,84 @@ +import { StarIcon, Tooltip } from '@pluralsh/design-system' +import { MouseEvent } from 'react' +import styled, { useTheme } from 'styled-components' + +const STAR_SIZE = 14 + +function FavoriteStarGlyph({ + size = STAR_SIZE, + color, +}: { + size?: number + color: string +}) { + return ( + + + + ) +} + +export function FlowFavoriteStar({ size = 16 }: { size?: number }) { + const theme = useTheme() + + return ( + + ) +} + +export function FlowFavoriteButton({ + favorited, + onToggle, +}: { + favorited: boolean + onToggle: () => void +}) { + const label = favorited ? 'Unfavorite' : 'Favorite' + + return ( + + { + e.preventDefault() + e.stopPropagation() + onToggle() + }} + > + {favorited ? ( + + ) : ( + + )} + + + ) +} + +const StarButtonSC = styled.button(({ theme }) => ({ + ...theme.partials.reset.button, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + lineHeight: 0, +})) diff --git a/js/console/src/components/flows/Flows.tsx b/js/console/src/components/flows/Flows.tsx index f342a064d5..d2ef2f538b 100644 --- a/js/console/src/components/flows/Flows.tsx +++ b/js/console/src/components/flows/Flows.tsx @@ -11,22 +11,47 @@ import { EmptyState, } from '@pluralsh/design-system' import { EmptyStateCompact } from 'components/ai/AIThreads' +import { FlowCard } from 'components/flows/FlowCard' +import { FlowsDisplayPanel } from 'components/flows/FlowsDisplayPanel' +import { FlowsTable } from 'components/flows/FlowsTable' +import { + DEFAULT_FLOWS_DISPLAY, + FlowsDisplayState, + getFlowFilterEmptyKind, + hasUncheckedFlowFilters, + parseFavoriteIds, + parseFlowsView, + resetFlowFilters, + toFlowFilterVariables, +} from 'components/flows/flowsDisplay' +import usePersistedState from 'components/hooks/usePersistedState' import { useThrottle } from 'components/hooks/useThrottle' import { CardGrid } from 'components/self-service/catalog/CatalogsGrid' import { GqlError } from 'components/utils/Alert' +import { + DisplayButton, + DisplayContentSC, + DisplayFilterEmpty, + DisplayMainSC, + DisplayToolbarSC, + toggleListValue, +} from 'components/utils/display/DisplayPanel' import LoadingIndicator from 'components/utils/LoadingIndicator' import { useFetchPaginatedData } from 'components/utils/table/useFetchPaginatedData' import { Body2P, InlineA, Subtitle1H1 } from 'components/utils/typography/Text' -import { useFlowsQuery } from 'generated/graphql' -import { isEmpty } from 'lodash' -import { useSearchParams } from 'react-router-dom' +import { ServiceDeploymentStatus, useFlowsQuery } from 'generated/graphql' +import { compact, isEmpty } from 'lodash' +import { useMemo, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { AI_MCP_SERVERS_ABS_PATH } from 'routes/aiRoutesConsts' import { FLOWS_ABS_PATH } from 'routes/flowRoutesConsts' import styled, { useTheme } from 'styled-components' import { mapExistingNodes } from 'utils/graphql' -import { FlowCard } from './FlowCard' const breadcrumbs: Breadcrumb[] = [{ label: 'flows', url: FLOWS_ABS_PATH }] export const FLOW_DOCS_URL = 'https://docs.plural.sh/plural-features/flows' +const FLOWS_VIEW_STORAGE_KEY = 'flows-view' +const FLOWS_FAVORITES_STORAGE_KEY = 'flows-favorites' export function Flows() { useSetBreadcrumbs(breadcrumbs) @@ -34,18 +59,130 @@ export function Flows() { const [searchParams, setSearchParams] = useSearchParams() const searchString = searchParams.get('q') ?? '' const debouncedSearchString = useThrottle(searchString, 200) + const [persistedView, setPersistedView] = usePersistedState( + FLOWS_VIEW_STORAGE_KEY, + DEFAULT_FLOWS_DISPLAY.view, + 0, + parseFlowsView + ) + const [favoriteIds, setFavoriteIds] = usePersistedState( + FLOWS_FAVORITES_STORAGE_KEY, + [] as string[], + 0, + parseFavoriteIds + ) + const [displayOpen, setDisplayOpen] = useState(false) + const [display, setDisplay] = useState(() => ({ + ...DEFAULT_FLOWS_DISPLAY, + view: persistedView, + })) + const filterVars = useMemo( + () => toFlowFilterVariables(display, favoriteIds), + [display, favoriteIds] + ) + const updateDisplay = (next: FlowsDisplayState) => { + setDisplay(next) + setPersistedView(next.view) + } + const toggleFavorite = (id: string) => { + setFavoriteIds((ids) => toggleListValue(ids, id)) + } - const { data, error, loading, pageInfo, refetch, fetchNextPage } = - useFetchPaginatedData( - { queryHook: useFlowsQuery, keyPath: ['flows'] }, - { q: debouncedSearchString } - ) + const { + data, + error, + loading, + pageInfo, + refetch, + fetchNextPage, + setVirtualSlice, + } = useFetchPaginatedData( + { queryHook: useFlowsQuery, keyPath: ['flows'] }, + { q: debouncedSearchString, ...filterVars } + ) - const flows = mapExistingNodes(data?.flows) + const flows = useMemo(() => mapExistingNodes(data?.flows), [data]) + const statusCounts = useMemo( + () => + Object.fromEntries( + compact(data?.flowServiceCounts).map((entry) => [ + entry.status, + entry.count, + ]) + ) as Partial>, + [data] + ) const hasActiveSearch = !!debouncedSearchString - const isSearchPending = searchString !== debouncedSearchString || loading + const isSearchPending = + searchString !== debouncedSearchString || (loading && isEmpty(flows)) + const filterEmptyKind = getFlowFilterEmptyKind(display) - if (!data && loading) return + const listContent = () => { + if (error) return + if (filterEmptyKind) { + return ( + updateDisplay(resetFlowFilters(display))} + /> + ) + } + if ((!data && loading) || isSearchPending) return + if (isEmpty(flows)) { + if (hasUncheckedFlowFilters(display)) { + return ( + updateDisplay(resetFlowFilters(display))} + /> + ) + } + return hasActiveSearch ? ( + + + + ) : ( + + ) + } + if (display.view === 'list') { + return ( + + ) + } + return ( + + !loading && pageInfo?.hasNextPage && fetchNextPage() + } + > + {flows.map((flow) => ( + toggleFavorite(flow.id)} + /> + ))} + + ) + } return ( @@ -57,49 +194,44 @@ export function Flows() { units. Learn more + - } - value={searchString} - onChange={(e) => - setSearchParams( - { ...(e.currentTarget.value && { q: e.currentTarget.value }) }, - { replace: true } - ) - } - /> - {error && } - {isSearchPending ? ( - - ) : isEmpty(flows) ? ( - hasActiveSearch ? ( - - - - ) : ( - - ) - ) : ( - - !loading && pageInfo?.hasNextPage && fetchNextPage() + + } + value={searchString} + onChange={(e) => + setSearchParams( + { ...(e.currentTarget.value && { q: e.currentTarget.value }) }, + { replace: true } + ) } - > - {flows.map((flow) => ( - - ))} - - )} + /> + setDisplayOpen(!displayOpen)} + /> + + + {listContent()} + {displayOpen && ( + + )} + ) } @@ -142,8 +274,9 @@ const WrapperSC = styled.div(({ theme }) => ({ padding: theme.spacing.large, })) -const HeaderSC = styled.div({ +const HeaderSC = styled.div(({ theme }) => ({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', -}) + gap: theme.spacing.medium, +})) diff --git a/js/console/src/components/flows/FlowsDisplayPanel.tsx b/js/console/src/components/flows/FlowsDisplayPanel.tsx new file mode 100644 index 0000000000..53b3c816ea --- /dev/null +++ b/js/console/src/components/flows/FlowsDisplayPanel.tsx @@ -0,0 +1,94 @@ +import { Radio } from '@pluralsh/design-system' +import { serviceStatusToLabel } from 'components/cd/services/ServiceStatusChip' +import { + DisplayFilterRow, + DisplayFilterRows, + DisplayPanel, + DisplayRadioGroup, + DisplaySection, + DisplaySectionHeader, + DisplaySortHeader, + DisplayViewToggle, + toggleListValue, +} from 'components/utils/display/DisplayPanel' +import { + FlowSort, + SortDirection, + ServiceDeploymentStatus, +} from 'generated/graphql' +import { FLOW_HEALTH_OPTIONS, FlowsDisplayState } from './flowsDisplay' + +export function FlowsDisplayPanel({ + state, + onChange, + statusCounts, +}: { + state: FlowsDisplayState + onChange: (next: FlowsDisplayState) => void + statusCounts: Partial> +}) { + return ( + + onChange({ ...state, view })} + /> + + Service health + + {FLOW_HEALTH_OPTIONS.map((status) => ( + + onChange({ + ...state, + statuses: toggleListValue(state.statuses, status), + }) + } + /> + ))} + + + + + onChange({ + ...state, + direction: + state.direction === SortDirection.Desc + ? SortDirection.Asc + : SortDirection.Desc, + }) + } + /> + onChange({ ...state, sort: value as FlowSort })} + > + + Flow name + + + Number of services + + + Favorited flows + + + + + ) +} diff --git a/js/console/src/components/flows/FlowsTable.tsx b/js/console/src/components/flows/FlowsTable.tsx new file mode 100644 index 0000000000..2121ba816c --- /dev/null +++ b/js/console/src/components/flows/FlowsTable.tsx @@ -0,0 +1,275 @@ +import { + AppIcon, + CaretRightIcon, + FlowIcon, + IconFrame, + Table, +} from '@pluralsh/design-system' +import { createColumnHelper } from '@tanstack/react-table' +import { FlowActionsMenu } from 'components/flows/FlowActionsMenu' +import { FlowFavoriteStar } from 'components/flows/FlowFavoriteButton' +import { + FlowAlertChip, + FlowHealthStacked, + FlowPipelineChip, + componentHealthCounts, + flowTabPath, + worstHealth, +} from 'components/flows/flowHealth' +import { VirtualSlice } from 'components/utils/table/useFetchPaginatedData' +import { CaptionP } from 'components/utils/typography/Text' +import { TRUNCATE } from 'components/utils/truncate' +import { FlowBasicWithBindingsFragment } from 'generated/graphql' +import { isEmpty } from 'lodash' +import { useMemo } from 'react' +import { Link, useLocation } from 'react-router-dom' +import styled from 'styled-components' + +const columnHelper = createColumnHelper() + +function getColumns({ + search, + favoriteIds, + onToggleFavorite, + refetch, +}: { + search: string + favoriteIds: string[] + onToggleFavorite: (id: string) => void + refetch: () => void +}) { + return [ + columnHelper.accessor((flow) => flow, { + id: 'actions', + header: '', + meta: { gridTemplate: 'min-content' }, + cell: function Cell({ getValue }) { + const flow = getValue() + + return ( + onToggleFavorite(flow.id)} + /> + ) + }, + }), + columnHelper.accessor((flow) => flow, { + id: 'name', + header: '', + meta: { gridTemplate: 'minmax(240px, 2fr)' }, + cell: function Cell({ getValue }) { + const flow = getValue() + const favorited = favoriteIds.includes(flow.id) + + return ( + + } + /> + + + {flow.name} + {favorited && ( + + + + )} + + {flow.description && ( + + {flow.description} + + )} + + + ) + }, + }), + columnHelper.accessor((flow) => flow, { + id: 'pipelines', + header: 'Pipelines', + meta: { gridTemplate: 'minmax(140px, 1fr)' }, + cell: function Cell({ getValue }) { + const flow = getValue() + + return ( + + ) + }, + }), + columnHelper.accessor((flow) => flow, { + id: 'components', + header: 'Components', + meta: { gridTemplate: 'minmax(160px, 1fr)' }, + cell: function Cell({ getValue }) { + const flow = getValue() + const counts = componentHealthCounts(flow.componentStatuses) + const bucket = worstHealth(counts) + + return ( + + ) + }, + }), + columnHelper.accessor((flow) => flow.serviceCount, { + id: 'services', + header: 'Services', + meta: { gridTemplate: 'minmax(80px, 0.6fr)' }, + cell: function Cell({ getValue }) { + return ( + + {getValue() ?? 0} + + ) + }, + }), + columnHelper.accessor((flow) => flow, { + id: 'alerts', + header: '', + meta: { gridTemplate: 'min-content' }, + cell: function Cell({ getValue }) { + const flow = getValue() + + return ( + + ) + }, + }), + columnHelper.display({ + id: 'arrow', + header: '', + meta: { gridTemplate: 'min-content' }, + cell: () => ( + } + size="medium" + type="tertiary" + /> + ), + }), + ] +} + +export function FlowsTable({ + flows, + loading, + hasNextPage, + fetchNextPage, + setVirtualSlice, + favoriteIds, + onToggleFavorite, + refetch, +}: { + flows: FlowBasicWithBindingsFragment[] + loading: boolean + hasNextPage: boolean + fetchNextPage: () => void + setVirtualSlice: (slice: VirtualSlice) => void + favoriteIds: string[] + onToggleFavorite: (id: string) => void + refetch: () => void +}) { + const { search } = useLocation() + const columns = useMemo( + () => getColumns({ search, favoriteIds, onToggleFavorite, refetch }), + [search, favoriteIds, onToggleFavorite, refetch] + ) + + return ( + { + const flow = original as FlowBasicWithBindingsFragment + + return + }} + emptyStateProps={{ message: 'No flows found' }} + /> + ) +} + +const NameAppIconSC = styled(AppIcon)({ + width: 32, + height: 32, + minWidth: 32, + minHeight: 32, + '& img, & svg': { + width: 16, + height: 16, + }, +}) + +const NameCellSC = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing.small, + minWidth: 0, + maxWidth: '100%', +})) + +const NameBlockSC = styled.div({ + display: 'flex', + flexDirection: 'column', + minWidth: 0, + maxWidth: '100%', +}) + +const NameRowSC = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing.xxsmall, + minWidth: 0, + maxWidth: '100%', + width: 'max-content', +})) + +const FavoriteMarkSC = styled.span({ + display: 'inline-flex', + flexShrink: 0, + lineHeight: 0, +}) + +const NameP = styled.p(({ theme }) => ({ + ...theme.partials.text.body2LooseLineHeight, + ...TRUNCATE, + margin: 0, + minWidth: 0, + color: theme.colors['text-light'], +})) diff --git a/js/console/src/components/flows/flow/FlowServices.tsx b/js/console/src/components/flows/flow/FlowServices.tsx index cbc35d8c3b..18030f63fa 100644 --- a/js/console/src/components/flows/flow/FlowServices.tsx +++ b/js/console/src/components/flows/flow/FlowServices.tsx @@ -1,6 +1,12 @@ -import { Table } from '@pluralsh/design-system' +import { Chip, Flex, Table } from '@pluralsh/design-system' import { Row } from '@tanstack/react-table' import { columns } from 'components/cd/services/Services' +import { + BUCKET_SERVICE_STATUSES, + BUCKET_SEVERITY, + FLOW_COMPONENT_PARAM, + parseComponentBucket, +} from 'components/flows/flowHealth' import { GqlError } from 'components/utils/Alert' import { useFetchPaginatedData } from 'components/utils/table/useFetchPaginatedData' import { @@ -8,13 +14,19 @@ import { useFlowServicesQuery, } from 'generated/graphql' import { useMemo } from 'react' -import { useNavigate, useOutletContext } from 'react-router-dom' +import { + useNavigate, + useOutletContext, + useSearchParams, +} from 'react-router-dom' import { Edge } from 'utils/graphql' import type { FlowOutletContext } from './Flow' export function FlowServices() { const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const { flow } = useOutletContext() + const bucket = parseComponentBucket(searchParams.get(FLOW_COMPONENT_PARAM)) const { data, loading, @@ -28,24 +40,66 @@ export function FlowServices() { { id: flow?.id ?? '' } ) const reactTableOptions = useMemo(() => ({ meta: { refetch } }), [refetch]) + const services = useMemo(() => { + const edges = data?.flow?.services?.edges ?? [] + + if (!bucket) return edges + + return edges.filter( + (edge) => + !!edge?.node?.status && + BUCKET_SERVICE_STATUSES[bucket].includes(edge.node.status) + ) + }, [bucket, data?.flow?.services?.edges]) if (error) return return ( -
>) => - navigate(original.node?.id ?? '') - } - hasNextPage={pageInfo?.hasNextPage} - fetchNextPage={fetchNextPage} - isFetchingNextPage={loading} - reactTableOptions={reactTableOptions} - onVirtualSliceChange={setVirtualSlice} - /> + + {bucket && ( + + setSearchParams((params) => { + params.delete(FLOW_COMPONENT_PARAM) + return params + }), + }} + > + {bucket} services + + )} +
> + ) => navigate(original.node?.id ?? '')} + hasNextPage={pageInfo?.hasNextPage} + fetchNextPage={fetchNextPage} + isFetchingNextPage={loading} + reactTableOptions={reactTableOptions} + onVirtualSliceChange={setVirtualSlice} + emptyStateProps={{ + message: bucket + ? `No ${bucket} services found.` + : 'No services found', + }} + /> + ) } diff --git a/js/console/src/components/flows/flowHealth.test.tsx b/js/console/src/components/flows/flowHealth.test.tsx new file mode 100644 index 0000000000..529579a876 --- /dev/null +++ b/js/console/src/components/flows/flowHealth.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import { + HonorableThemeProvider, + styledThemeDark, +} from '@pluralsh/design-system' +import { MemoryRouter } from 'react-router-dom' +import { ThemeProvider } from 'styled-components' +import { describe, expect, it } from 'vitest' +import { FlowPipelineChip } from './flowHealth' + +function renderPipelineChip({ + pipelineCount, + pendingCount, + stoppedCount, +}: { + pipelineCount: number + pendingCount: number + stoppedCount?: number +}) { + return render( + + + + + + + + ) +} + +describe('FlowPipelineChip', () => { + it('shows the total pipeline count alongside pending pipelines', () => { + renderPipelineChip({ pipelineCount: 5, pendingCount: 1 }) + + expect(screen.getByText('5 pipelines')).toBeTruthy() + expect(screen.getByText('1 pending')).toBeTruthy() + }) + + it('shows the total pipeline count alongside stopped pipelines', () => { + renderPipelineChip({ pipelineCount: 2, pendingCount: 0, stoppedCount: 1 }) + + expect(screen.getByText('2 pipelines')).toBeTruthy() + expect(screen.getByText('1 stopped')).toBeTruthy() + }) +}) diff --git a/js/console/src/components/flows/flowHealth.tsx b/js/console/src/components/flows/flowHealth.tsx new file mode 100644 index 0000000000..f3ee6f949c --- /dev/null +++ b/js/console/src/components/flows/flowHealth.tsx @@ -0,0 +1,308 @@ +import { Chip, ChipProps, SemanticColorKey } from '@pluralsh/design-system' +import { StackedText } from 'components/utils/table/StackedText' +import { CaptionP } from 'components/utils/typography/Text' +import { + ComponentState, + ComponentStatusCount, + ServiceDeploymentStatus, +} from 'generated/graphql' +import { compact, startCase } from 'lodash' +import pluralize from 'pluralize' +import { MouseEvent, ReactNode, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { getFlowDetailsPath } from 'routes/flowRoutesConsts' +import styled from 'styled-components' + +export type HealthBucket = 'failed' | 'stale' | 'healthy' + +export type FlowTab = 'services' | 'alerts' | 'pipelines' + +export const FLOW_COMPONENT_PARAM = 'component' + +const BUCKETS: HealthBucket[] = ['failed', 'stale', 'healthy'] + +const STATE_BUCKET: Partial> = { + [ComponentState.Failed]: 'failed', + [ComponentState.Pending]: 'stale', + [ComponentState.Paused]: 'stale', + [ComponentState.Running]: 'healthy', +} + +export const BUCKET_SEVERITY = { + failed: 'danger', + stale: 'warning', + healthy: 'success', +} as const satisfies Record + +const BUCKET_TEXT = { + failed: 'text-danger-light', + stale: 'text-warning-light', + healthy: 'text-success-light', +} as const satisfies Record + +export const BUCKET_SERVICE_STATUSES: Record< + HealthBucket, + ServiceDeploymentStatus[] +> = { + failed: [ServiceDeploymentStatus.Failed], + stale: [ServiceDeploymentStatus.Stale, ServiceDeploymentStatus.Paused], + healthy: [ServiceDeploymentStatus.Healthy, ServiceDeploymentStatus.Synced], +} + +const chipCss = { + width: 'max-content', + flexShrink: 0, + pointerEvents: 'auto', +} as const + +export function componentHealthCounts( + statuses: Nullable[]> | undefined +): Record { + return compact(statuses).reduce( + (counts, { state, count }) => { + const bucket = state ? STATE_BUCKET[state] : undefined + if (bucket) counts[bucket] += count + return counts + }, + { failed: 0, stale: 0, healthy: 0 } + ) +} + +export function worstHealth( + counts: Record +): HealthBucket | null { + return BUCKETS.find((bucket) => counts[bucket] > 0) ?? null +} + +export function parseComponentBucket( + value: string | null | undefined +): HealthBucket | null { + return BUCKETS.includes(value as HealthBucket) + ? (value as HealthBucket) + : null +} + +export function flowTabPath( + flowName: string, + tab: FlowTab, + search = '', + component?: HealthBucket | null +) { + const params = new URLSearchParams(search) + + if (component) params.set(FLOW_COMPONENT_PARAM, component) + else params.delete(FLOW_COMPONENT_PARAM) + + const qs = params.toString() + + return `${getFlowDetailsPath({ flowIdOrName: flowName })}/${tab}${qs ? `?${qs}` : ''}` +} + +function bucketPhrase(bucket: HealthBucket, count: number) { + return `${count} ${bucket}` +} + +function useStopNav(to?: string) { + const navigate = useNavigate() + + return useCallback( + (event: MouseEvent) => { + if (!to) return + event.preventDefault() + event.stopPropagation() + navigate(to) + }, + [navigate, to] + ) +} + +function FlowNavChip({ + to, + inactive, + severity, + children, + css: extraCss, +}: { + to?: string + inactive?: ChipProps['inactive'] + severity: ChipProps['severity'] + children: ReactNode + css?: ChipProps['css'] +}) { + const onClick = useStopNav(to) + + return ( + + {children} + + ) +} + +export function FlowHealthChips({ + counts, + getTo, +}: { + counts: Record + getTo?: (bucket?: HealthBucket) => string +}) { + const chips = BUCKETS.filter((bucket) => counts[bucket] > 0) + + return ( + + {chips.length === 0 ? ( + + 0 healthy + + ) : ( + chips.map((bucket) => ( + + {bucketPhrase(bucket, counts[bucket])} + + )) + )} + + ) +} + +export function FlowAlertChip({ count, to }: { count: number; to?: string }) { + return ( + + {count} {pluralize('alert', count)} + + ) +} + +export function FlowPipelineChip({ + pipelineCount, + pendingCount, + stoppedCount = 0, + to, +}: { + pipelineCount: number + pendingCount: number + stoppedCount?: number + to?: string +}) { + const pending = pendingCount > 0 + const stopped = stoppedCount > 0 + + return ( + + {`${pipelineCount} ${pluralize('pipeline', pipelineCount)}`} + {pending && ( + <> + · + + {pendingCount} pending + + + )} + {stopped && ( + <> + · + + {stoppedCount} stopped + + + )} + + ) +} + +export function FlowHealthStacked({ + counts, + to, +}: { + counts: Record + to?: string +}) { + const onClick = useStopNav(to) + const worst = worstHealth(counts) + const caption = BUCKETS.filter((bucket) => counts[bucket] > 0) + .map((bucket) => bucketPhrase(bucket, counts[bucket])) + .join(' · ') + + if (!worst || !caption) { + return ( + + — + + ) + } + + const stacked = ( + + ) + + if (!to) return stacked + + return ( + + {stacked} + + ) +} + +const PipelineStatusSC = styled.span<{ $tone: 'pending' | 'stopped' }>( + ({ theme, $tone }) => ({ + color: + $tone === 'pending' + ? theme.colors['text-warning-light'] + : theme.colors['text-danger-light'], + }) +) + +const ChipsSC = styled.div(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing.xxsmall, +})) + +const StackedButtonSC = styled.button(({ theme }) => ({ + ...theme.partials.reset.button, + pointerEvents: 'auto', + textAlign: 'left', + cursor: 'pointer', + '&:hover': { + textDecoration: 'underline', + }, +})) diff --git a/js/console/src/components/flows/flowsDisplay.ts b/js/console/src/components/flows/flowsDisplay.ts new file mode 100644 index 0000000000..a2f6926462 --- /dev/null +++ b/js/console/src/components/flows/flowsDisplay.ts @@ -0,0 +1,79 @@ +import { DisplayView } from 'components/utils/display/DisplayPanel' +import { + FlowSort, + SortDirection, + ServiceDeploymentStatus, +} from 'generated/graphql' +import { isEmpty, xor } from 'lodash' + +export type FlowsView = DisplayView + +export type FlowsDisplayState = { + view: FlowsView + statuses: ServiceDeploymentStatus[] + sort: FlowSort + direction: SortDirection +} + +export const FLOW_HEALTH_OPTIONS = Object.values(ServiceDeploymentStatus) + +export const DEFAULT_FLOWS_DISPLAY: FlowsDisplayState = { + view: 'board', + statuses: [...FLOW_HEALTH_OPTIONS], + sort: FlowSort.Name, + direction: SortDirection.Asc, +} + +export function allFlowHealthSelected( + statuses: ServiceDeploymentStatus[] +): boolean { + return isEmpty(xor(statuses, FLOW_HEALTH_OPTIONS)) +} + +export function hasUncheckedFlowFilters({ + statuses, +}: Pick): boolean { + return !allFlowHealthSelected(statuses) +} + +export function getFlowFilterEmptyKind({ + statuses, +}: Pick): 'health' | null { + if (isEmpty(statuses)) return 'health' + return null +} + +export function resetFlowFilters(state: FlowsDisplayState): FlowsDisplayState { + return { + ...state, + statuses: DEFAULT_FLOWS_DISPLAY.statuses, + } +} + +export function toFlowFilterVariables( + { statuses, sort, direction }: FlowsDisplayState, + favoriteIds: string[] +): { + statuses?: ServiceDeploymentStatus[] + sort?: FlowSort + direction?: SortDirection + favoriteIds?: string[] +} { + const defaultSort = sort === FlowSort.Name && direction === SortDirection.Asc + + return { + statuses: allFlowHealthSelected(statuses) ? undefined : statuses, + sort: defaultSort ? undefined : sort, + direction: defaultSort ? undefined : direction, + favoriteIds: sort === FlowSort.Favorited ? favoriteIds : undefined, + } +} + +export function parseFlowsView(value: unknown): FlowsView { + return value === 'list' ? 'list' : 'board' +} + +export function parseFavoriteIds(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((id): id is string => typeof id === 'string') +} diff --git a/js/console/src/components/utils/display/DisplayPanel.tsx b/js/console/src/components/utils/display/DisplayPanel.tsx new file mode 100644 index 0000000000..5943974d9e --- /dev/null +++ b/js/console/src/components/utils/display/DisplayPanel.tsx @@ -0,0 +1,355 @@ +import { + Button, + Card, + Checkbox, + Chip, + DiffColumnIcon, + DiffUnifiedIcon, + FiltersIcon, + Flex, + IconFrame, + RadioGroup, + SortAscIcon, + SortDescIcon, +} from '@pluralsh/design-system' +import { Body1BoldP, Body2P } from 'components/utils/typography/Text' +import { xor } from 'lodash' +import { ComponentProps, ReactElement, ReactNode } from 'react' +import styled from 'styled-components' + +export type DisplayView = 'list' | 'board' + +export function toggleListValue(list: T[], value: T): T[] { + return xor(list, [value]) +} + +export function DisplayButton({ + showDot, + onClick, +}: { + showDot: boolean + onClick: () => void +}) { + return ( + + ) +} + +export function DisplayPanel({ children }: { children: ReactNode }) { + return {children} +} + +export function DisplayViewToggle({ + view, + onChange, +}: { + view: DisplayView + onChange: (view: DisplayView) => void +}) { + return ( + + } + onClick={() => onChange('list')} + > + List + + } + onClick={() => onChange('board')} + > + Board + + + ) +} + +export function DisplaySection({ children }: { children: ReactNode }) { + return {children} +} + +export function DisplaySectionHeader({ children }: { children: ReactNode }) { + return {children} +} + +export function DisplayFilterRows({ + compact, + children, +}: { + compact?: boolean + children: ReactNode +}) { + return {children} +} + +export function DisplayFilterRow({ + label, + count, + checked, + onChange, +}: { + label: string + count: number + checked: boolean + onChange: () => void +}) { + return ( + + + {label} + + {count} + + ) +} + +export function DisplaySortHeader({ + descending, + onToggle, +}: { + descending: boolean + onToggle: () => void +}) { + return ( + + Sort by + : } + onClick={onToggle} + css={{ + width: 28, + height: 20, + borderRadius: 6, + '& svg': { width: 12, height: 12 }, + }} + /> + + ) +} + +export function DisplayRadioGroup(props: ComponentProps) { + return +} + +export function DisplayFilterEmpty({ + title, + description, + onReset, +}: { + title: string + description: string + onReset: () => void +}) { + return ( + + + {title} + + {description} + + + + + ) +} + +function ViewChip({ + selected, + icon, + onClick, + children, +}: { + selected: boolean + icon: ReactElement + onClick: () => void + children: string +}) { + return ( + + {children} + + ) +} + +export const DisplayToolbarSC = styled(Flex)(({ theme }) => ({ + alignItems: 'center', + gap: theme.spacing.medium, +})) + +export const DisplayContentSC = styled(Flex)(({ theme }) => ({ + flex: 1, + gap: theme.spacing.medium, + minHeight: 0, +})) + +export const DisplayMainSC = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + minWidth: 0, +}) + +const DisplayLabelSC = styled.span(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing.xsmall, +})) + +const DisplayFilterDotSC = styled.span(({ theme }) => ({ + width: 8, + height: 8, + borderRadius: '50%', + backgroundColor: theme.colors['text-primary-accent'], + flexShrink: 0, +})) + +const PanelSC = styled(Card)(({ theme }) => ({ + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + alignSelf: 'stretch', + height: '100%', + maxHeight: '100%', + minHeight: 0, + overflowY: 'auto', + padding: `0 ${theme.spacing.medium}px`, + width: 230, +})) + +const ViewToggleSC = styled.div(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: theme.spacing.xxsmall, + padding: `${theme.spacing.medium}px 0`, +})) + +const SectionSC = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + borderBottom: theme.borders.default, +})) + +const SectionHeaderSC = styled.div(({ theme }) => ({ + ...theme.partials.text.body2Bold, + color: theme.colors.text, + paddingTop: theme.spacing.medium, + paddingBottom: theme.spacing.xxsmall, +})) + +const SectionTitleSC = styled.span(({ theme }) => ({ + ...theme.partials.text.body2Bold, + color: theme.colors.text, +})) + +const SortHeaderSC = styled(Flex)(({ theme }) => ({ + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: theme.spacing.medium, + paddingBottom: theme.spacing.xxsmall, +})) + +const FilterRowsSC = styled.div<{ $compact?: boolean }>( + ({ theme, $compact }) => ({ + display: 'flex', + flexDirection: 'column', + paddingTop: theme.spacing.xxsmall, + paddingBottom: $compact ? theme.spacing.xxsmall : theme.spacing.medium, + }) +) + +const FilterRowSC = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing.xxsmall, + '& label': { + flex: 1, + minWidth: 0, + }, +})) + +const CountSC = styled.span(({ theme }) => ({ + ...theme.partials.text.body2, + color: theme.colors['text-input-disabled'], + flexShrink: 0, +})) + +const RadioGroupSC = styled(RadioGroup)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + paddingTop: theme.spacing.xxsmall, + paddingBottom: theme.spacing.medium, +})) + +const EmptyWrapperSC = styled(Card)(({ theme }) => ({ + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing.small, + height: 540, + maxHeight: '100%', + width: '100%', + minHeight: 160, + padding: `${theme.spacing.xlarge}px ${theme.spacing.medium}px`, +})) + +const EmptyCopySC = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacing.xxsmall, +})) diff --git a/js/console/src/components/workbenches/workbench/WorkbenchIssues.tsx b/js/console/src/components/workbenches/workbench/WorkbenchIssues.tsx index 5ac3d778db..b6ef3fc41c 100644 --- a/js/console/src/components/workbenches/workbench/WorkbenchIssues.tsx +++ b/js/console/src/components/workbenches/workbench/WorkbenchIssues.tsx @@ -1,14 +1,15 @@ -import { - Button, - FiltersIcon, - Flex, - Input2, - SearchIcon, -} from '@pluralsh/design-system' +import { Flex, Input2, SearchIcon } from '@pluralsh/design-system' import { useDebounce } from '@react-hooks-library/core' import { WorkbenchIssuesBoard } from 'components/workbenches/common/WorkbenchIssuesBoard' import { WorkbenchIssuesTable } from 'components/workbenches/common/WorkbenchIssuesTable' import { GqlError } from 'components/utils/Alert' +import { + DisplayButton, + DisplayContentSC, + DisplayFilterEmpty, + DisplayMainSC, + DisplayToolbarSC, +} from 'components/utils/display/DisplayPanel' import usePersistedState from 'components/hooks/usePersistedState' import { useFetchPaginatedData } from 'components/utils/table/useFetchPaginatedData' import { @@ -24,7 +25,6 @@ import styled from 'styled-components' import { mapExistingNodes } from 'utils/graphql' import { WorkbenchPageLayout } from './Workbench' import { WorkbenchIssuesDisplayPanel } from './WorkbenchIssuesDisplayPanel' -import { WorkbenchIssuesFilterEmpty } from './WorkbenchIssuesEmpty' import { DEFAULT_WORKBENCH_ISSUES_DISPLAY, getIssueFilterEmptyKind, @@ -105,7 +105,7 @@ export function WorkbenchIssues() { ) : ( - + setSearchString(e.currentTarget.value)} /> - - - - + /> + + + {filterEmptyKind ? ( - updateDisplay(resetIssueFilters(display))} /> ) : display.view === 'board' ? ( @@ -151,7 +146,7 @@ export function WorkbenchIssues() { fallbackWorkbenchId={workbenchId} /> )} - + {displayOpen && ( )} - + )} @@ -175,36 +170,3 @@ const WrapperSC = styled(Flex)(({ theme }) => ({ overflow: 'hidden', padding: `${theme.spacing.medium}px ${theme.spacing.large}px`, })) - -const ToolbarSC = styled(Flex)(({ theme }) => ({ - alignItems: 'center', - gap: theme.spacing.medium, -})) - -const DisplayLabelSC = styled.span(({ theme }) => ({ - display: 'inline-flex', - alignItems: 'center', - gap: theme.spacing.xsmall, -})) - -const DisplayFilterDotSC = styled.span(({ theme }) => ({ - width: 8, - height: 8, - borderRadius: '50%', - backgroundColor: theme.colors['text-primary-accent'], - flexShrink: 0, -})) - -const ContentSC = styled(Flex)(({ theme }) => ({ - flex: 1, - gap: theme.spacing.medium, - minHeight: 0, -})) - -const TableContainerSC = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, - minHeight: 0, - minWidth: 0, -}) diff --git a/js/console/src/components/workbenches/workbench/WorkbenchIssuesDisplayPanel.tsx b/js/console/src/components/workbenches/workbench/WorkbenchIssuesDisplayPanel.tsx index 876d9c1d80..0fd6a90033 100644 --- a/js/console/src/components/workbenches/workbench/WorkbenchIssuesDisplayPanel.tsx +++ b/js/console/src/components/workbenches/workbench/WorkbenchIssuesDisplayPanel.tsx @@ -1,31 +1,27 @@ +import { Radio } from '@pluralsh/design-system' import { - Card, - Checkbox, - Chip, - DiffColumnIcon, - DiffUnifiedIcon, - Flex, - IconFrame, - Radio, - RadioGroup, - SortAscIcon, - SortDescIcon, -} from '@pluralsh/design-system' + DisplayFilterRow, + DisplayFilterRows, + DisplayPanel, + DisplayRadioGroup, + DisplaySection, + DisplaySectionHeader, + DisplaySortHeader, + DisplayViewToggle, + toggleListValue, +} from 'components/utils/display/DisplayPanel' +import { + ISSUE_STATUS_LABELS, + ISSUE_STATUS_OPTIONS, +} from 'components/workbenches/common/issueStatus' import { IssueSort, - IssueSortDirection, + SortDirection, IssueStatus, IssueWebhookProvider, } from 'generated/graphql' -import { includes, startCase } from 'lodash' -import { ReactElement } from 'react' -import styled from 'styled-components' -import { - ISSUE_STATUS_LABELS, - ISSUE_STATUS_OPTIONS, -} from 'components/workbenches/common/issueStatus' +import { startCase } from 'lodash' import { - toggleListValue, visibleIssueProviders, WorkbenchIssuesDisplayState, } from './workbenchIssuesDisplay' @@ -44,32 +40,20 @@ export function WorkbenchIssuesDisplayPanel({ const providers = visibleIssueProviders(providerCounts) return ( - - - } - onClick={() => onChange({ ...state, view: 'list' })} - > - List - - } - onClick={() => onChange({ ...state, view: 'board' })} - > - Board - - - - Source from - + + onChange({ ...state, view })} + /> + + Source from + {providers.map((provider) => ( - onChange({ ...state, @@ -78,17 +62,17 @@ export function WorkbenchIssuesDisplayPanel({ } /> ))} - - - - Ticket status - + + + + Ticket status + {ISSUE_STATUS_OPTIONS.map((status) => ( - onChange({ ...state, @@ -97,46 +81,22 @@ export function WorkbenchIssuesDisplayPanel({ } /> ))} - - - - - Sort by - - ) : ( - - ) - } - onClick={() => - onChange({ - ...state, - direction: - state.direction === IssueSortDirection.Desc - ? IssueSortDirection.Asc - : IssueSortDirection.Desc, - }) - } - css={{ - width: 28, - height: 20, - borderRadius: 6, - '& svg': { width: 12, height: 12 }, - }} - /> - - + + + + onChange({ + ...state, + direction: + state.direction === SortDirection.Desc + ? SortDirection.Asc + : SortDirection.Desc, + }) + } + /> + onChange({ ...state, sort: value as IssueSort })} > @@ -152,145 +112,8 @@ export function WorkbenchIssuesDisplayPanel({ > Issue name - - - - ) -} - -function ViewChip({ - selected, - icon, - onClick, - children, -}: { - selected: boolean - icon: ReactElement - onClick: () => void - children: string -}) { - return ( - - {children} - + + + ) } - -function FilterRow({ - label, - count, - checked, - onChange, -}: { - label: string - count: number - checked: boolean - onChange: () => void -}) { - return ( - - onChange()} - > - {label} - - {count} - - ) -} - -const PanelSC = styled(Card)(({ theme }) => ({ - boxSizing: 'border-box', - display: 'flex', - flexDirection: 'column', - flexShrink: 0, - alignSelf: 'stretch', - height: '100%', - maxHeight: '100%', - minHeight: 0, - overflowY: 'auto', - padding: `0 ${theme.spacing.medium}px`, - width: 230, -})) - -const ViewToggleSC = styled.div(({ theme }) => ({ - display: 'grid', - gridTemplateColumns: '1fr 1fr', - gap: theme.spacing.xxsmall, - padding: `${theme.spacing.medium}px 0`, -})) - -const SectionSC = styled.div(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - borderBottom: theme.borders.default, -})) - -const SectionHeaderSC = styled.div(({ theme }) => ({ - ...theme.partials.text.body2Bold, - color: theme.colors.text, - paddingTop: theme.spacing.medium, - paddingBottom: theme.spacing.xxsmall, -})) - -const SectionTitleSC = styled.span(({ theme }) => ({ - ...theme.partials.text.body2Bold, - color: theme.colors.text, -})) - -const SortHeaderSC = styled(Flex)(({ theme }) => ({ - alignItems: 'center', - justifyContent: 'space-between', - paddingTop: theme.spacing.medium, - paddingBottom: theme.spacing.xxsmall, -})) - -const FilterRowsSC = styled.div<{ $compact?: boolean }>( - ({ theme, $compact }) => ({ - display: 'flex', - flexDirection: 'column', - paddingTop: theme.spacing.xxsmall, - paddingBottom: $compact ? theme.spacing.xxsmall : theme.spacing.medium, - }) -) - -const FilterRowSC = styled.div(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: theme.spacing.xxsmall, - '& label': { - flex: 1, - minWidth: 0, - }, -})) - -const CountSC = styled.span(({ theme }) => ({ - ...theme.partials.text.body2, - color: theme.colors['text-input-disabled'], - flexShrink: 0, -})) - -const RadioGroupSC = styled(RadioGroup)(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - paddingTop: theme.spacing.xxsmall, - paddingBottom: theme.spacing.medium, -})) diff --git a/js/console/src/components/workbenches/workbench/WorkbenchIssuesEmpty.tsx b/js/console/src/components/workbenches/workbench/WorkbenchIssuesEmpty.tsx deleted file mode 100644 index 22dcd75d16..0000000000 --- a/js/console/src/components/workbenches/workbench/WorkbenchIssuesEmpty.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Button, Card } from '@pluralsh/design-system' -import { Body1BoldP, Body2P } from 'components/utils/typography/Text' -import styled from 'styled-components' -import { IssueFilterEmptyKind } from './workbenchIssuesDisplay' - -export function WorkbenchIssuesFilterEmpty({ - kind, - onReset, -}: { - kind: IssueFilterEmptyKind - onReset: () => void -}) { - return ( - - - No {kind} selected - - It looks like there are no {kind} selected. - - - - - ) -} - -const WrapperSC = styled(Card)(({ theme }) => ({ - boxSizing: 'border-box', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - gap: theme.spacing.small, - height: 540, - maxHeight: '100%', - width: '100%', - minHeight: 160, - padding: `${theme.spacing.xlarge}px ${theme.spacing.medium}px`, -})) - -const CopySC = styled.div(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: theme.spacing.xxsmall, -})) diff --git a/js/console/src/components/workbenches/workbench/workbenchIssuesDisplay.ts b/js/console/src/components/workbenches/workbench/workbenchIssuesDisplay.ts index 7b38d7a26c..79ebcb7a7f 100644 --- a/js/console/src/components/workbenches/workbench/workbenchIssuesDisplay.ts +++ b/js/console/src/components/workbenches/workbench/workbenchIssuesDisplay.ts @@ -1,20 +1,21 @@ +import { DisplayView } from 'components/utils/display/DisplayPanel' import { IssueSort, - IssueSortDirection, + SortDirection, IssueStatus, IssueWebhookProvider, } from 'generated/graphql' import { intersection, isEmpty, xor } from 'lodash' import { ISSUE_STATUS_OPTIONS } from 'components/workbenches/common/issueStatus' -export type WorkbenchIssuesView = 'list' | 'board' +export type WorkbenchIssuesView = DisplayView export type WorkbenchIssuesDisplayState = { view: WorkbenchIssuesView providers: IssueWebhookProvider[] statuses: IssueStatus[] sort: IssueSort - direction: IssueSortDirection + direction: SortDirection } export const ALL_ISSUE_PROVIDERS = Object.values(IssueWebhookProvider) @@ -24,11 +25,7 @@ export const DEFAULT_WORKBENCH_ISSUES_DISPLAY: WorkbenchIssuesDisplayState = { providers: ALL_ISSUE_PROVIDERS, statuses: [...ISSUE_STATUS_OPTIONS], sort: IssueSort.InsertedAt, - direction: IssueSortDirection.Desc, -} - -export function toggleListValue(list: T[], value: T): T[] { - return xor(list, [value]) + direction: SortDirection.Desc, } export function visibleIssueProviders( @@ -94,10 +91,10 @@ export function toIssueFilterVariables({ providers?: IssueWebhookProvider[] statuses?: IssueStatus[] sort?: IssueSort - direction?: IssueSortDirection + direction?: SortDirection } { const defaultSort = - sort === IssueSort.InsertedAt && direction === IssueSortDirection.Desc + sort === IssueSort.InsertedAt && direction === SortDirection.Desc return { providers: allIssueProvidersSelected(providers) ? undefined : providers, diff --git a/js/console/src/generated/graphql.ts b/js/console/src/generated/graphql.ts index 5cd3b5f0dc..628f6308b7 100644 --- a/js/console/src/generated/graphql.ts +++ b/js/console/src/generated/graphql.ts @@ -3634,6 +3634,12 @@ export enum ComponentState { Running = 'RUNNING' } +export type ComponentStatusCount = { + __typename?: 'ComponentStatusCount'; + count: Scalars['Int']['output']; + state: ComponentState; +}; + /** A tree view of the kubernetes object hierarchy beneath a component */ export type ComponentTree = { __typename?: 'ComponentTree'; @@ -4553,7 +4559,13 @@ export type Flow = { __typename?: 'Flow'; /** the agent runtime for this flow */ agentRuntime?: Maybe; + /** the number of alerts for services in this flow */ + alertCount?: Maybe; alerts?: Maybe; + /** the number of service components in this flow */ + componentCount?: Maybe; + /** a rollup of component states in this flow */ + componentStatuses?: Maybe>>; description?: Maybe; icon?: Maybe; id: Scalars['ID']['output']; @@ -4563,6 +4575,10 @@ export type Flow = { maxPreviews?: Maybe; metadata?: Maybe; name: Scalars['String']['output']; + /** the number of pending pipeline gates in this flow */ + pendingPipelineCount?: Maybe; + /** the number of pipelines in this flow */ + pipelineCount?: Maybe; pipelines?: Maybe; previewEnvironmentInstances?: Maybe; previewEnvironmentTemplates?: Maybe; @@ -4575,6 +4591,10 @@ export type Flow = { repositories?: Maybe>>; /** servers that are bound to this flow */ servers?: Maybe>>; + /** the number of services in this flow */ + serviceCount?: Maybe; + /** a rollup of service statuses in this flow */ + serviceStatuses?: Maybe>>; services?: Maybe; updatedAt?: Maybe; vulnerabilityReports?: Maybe; @@ -4690,6 +4710,12 @@ export type FlowEdge = { node?: Maybe; }; +export enum FlowSort { + Favorited = 'FAVORITED', + Name = 'NAME', + ServiceCount = 'SERVICE_COUNT' +} + export type FlowWorkbenchAttributes = { /** the workbench to associate with this flow */ workbenchId?: InputMaybe; @@ -5727,11 +5753,6 @@ export enum IssueSort { Title = 'TITLE' } -export enum IssueSortDirection { - Asc = 'ASC', - Desc = 'DESC' -} - export enum IssueStatus { Cancelled = 'CANCELLED', Completed = 'COMPLETED', @@ -11716,6 +11737,7 @@ export type RootQueryType = { /** Fetches the manifests from cache once the agent has given us them, will be null otherwise */ fetchManifests?: Maybe>>; flow?: Maybe; + flowServiceCounts?: Maybe>>; flows?: Maybe; fluxHelmRepositories?: Maybe>>; fluxHelmRepository?: Maybe; @@ -12388,12 +12410,21 @@ export type RootQueryTypeFlowArgs = { }; +export type RootQueryTypeFlowServiceCountsArgs = { + q?: InputMaybe; +}; + + export type RootQueryTypeFlowsArgs = { after?: InputMaybe; before?: InputMaybe; + direction?: InputMaybe; + favoriteIds?: InputMaybe>>; first?: InputMaybe; last?: InputMaybe; q?: InputMaybe; + sort?: InputMaybe; + statuses?: InputMaybe>>; }; @@ -14967,6 +14998,11 @@ export type SmtpSettingsAttributes = { user: Scalars['String']['input']; }; +export enum SortDirection { + Asc = 'ASC', + Desc = 'DESC' +} + export type StackAttributes = { /** user id to use for default Plural authentication in this stack */ actorId?: InputMaybe; @@ -16472,7 +16508,7 @@ export type WorkbenchEvalResultsArgs = { export type WorkbenchIssuesArgs = { after?: InputMaybe; before?: InputMaybe; - direction?: InputMaybe; + direction?: InputMaybe; first?: InputMaybe; last?: InputMaybe; providers?: InputMaybe>>; @@ -20959,9 +20995,9 @@ export type ClusterIsoImagesQueryVariables = Exact<{ export type ClusterIsoImagesQuery = { __typename?: 'RootQueryType', clusterIsoImages?: { __typename?: 'ClusterIsoImageConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterIsoImageEdge', node?: { __typename?: 'ClusterIsoImage', id: string, user?: string | null, password?: string | null, registry: string, image: string, insertedAt?: string | null, project?: { __typename?: 'Project', name: string } | null } | null } | null> | null } | null }; -export type FlowBasicFragment = { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, project?: { __typename?: 'Project', id: string, name: string } | null, alerts?: { __typename?: 'AlertConnection', edges?: Array<{ __typename?: 'AlertEdge', node?: { __typename?: 'Alert', id: string } | null } | null> | null } | null }; +export type FlowBasicFragment = { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, serviceCount?: number | null, componentCount?: number | null, alertCount?: number | null, pipelineCount?: number | null, pendingPipelineCount?: number | null, project?: { __typename?: 'Project', id: string, name: string } | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string } | null, serviceStatuses?: Array<{ __typename?: 'ServiceStatusCount', count: number, status: ServiceDeploymentStatus } | null> | null, componentStatuses?: Array<{ __typename?: 'ComponentStatusCount', state: ComponentState, count: number } | null> | null }; -export type FlowBasicWithBindingsFragment = { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, project?: { __typename?: 'Project', id: string, name: string } | null, alerts?: { __typename?: 'AlertConnection', edges?: Array<{ __typename?: 'AlertEdge', node?: { __typename?: 'Alert', id: string } | null } | null> | null } | null }; +export type FlowBasicWithBindingsFragment = { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, serviceCount?: number | null, componentCount?: number | null, alertCount?: number | null, pipelineCount?: number | null, pendingPipelineCount?: number | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, project?: { __typename?: 'Project', id: string, name: string } | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string } | null, serviceStatuses?: Array<{ __typename?: 'ServiceStatusCount', count: number, status: ServiceDeploymentStatus } | null> | null, componentStatuses?: Array<{ __typename?: 'ComponentStatusCount', state: ComponentState, count: number } | null> | null }; export type PreviewEnvironmentTemplateFragment = { __typename?: 'PreviewEnvironmentTemplate', id: string, name: string, commentTemplate?: string | null, referenceService?: { __typename?: 'ServiceDeployment', id: string, name: string, cluster?: { __typename?: 'Cluster', id: string } | null } | null, template?: { __typename?: 'ServiceTemplate', contexts?: Array | null, name?: string | null, namespace?: string | null, repositoryId?: string | null, templated?: boolean | null, dependencies?: Array<{ __typename?: 'ServiceDependency', id: string, name: string, status?: ServiceDeploymentStatus | null } | null> | null, git?: { __typename?: 'GitRef', folder: string, ref: string } | null, helm?: { __typename?: 'HelmSpec', chart?: string | null, ignoreCrds?: boolean | null, ignoreHooks?: boolean | null, release?: string | null, url?: string | null, values?: string | null, valuesFiles?: Array | null, version?: string | null, git?: { __typename?: 'GitRef', folder: string, ref: string } | null, repository?: { __typename?: 'ObjectReference', name?: string | null, namespace?: string | null } | null, set?: Array<{ __typename?: 'HelmValue', name: string, value: string } | null> | null } | null, kustomize?: { __typename?: 'Kustomize', path: string, enableHelm?: boolean | null } | null, repository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null, recurseSubmodules?: boolean | null } | null, syncConfig?: { __typename?: 'SyncConfig', createNamespace?: boolean | null, enforceNamespace?: boolean | null, namespaceMetadata?: { __typename?: 'NamespaceMetadata', annotations?: Record | null, labels?: Record | null } | null } | null } | null }; @@ -20975,10 +21011,14 @@ export type FlowsQueryVariables = Exact<{ first?: InputMaybe; after?: InputMaybe; q?: InputMaybe; + statuses?: InputMaybe> | InputMaybe>; + sort?: InputMaybe; + direction?: InputMaybe; + favoriteIds?: InputMaybe> | InputMaybe>; }>; -export type FlowsQuery = { __typename?: 'RootQueryType', flows?: { __typename?: 'FlowConnection', edges?: Array<{ __typename?: 'FlowEdge', node?: { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, project?: { __typename?: 'Project', id: string, name: string } | null, alerts?: { __typename?: 'AlertConnection', edges?: Array<{ __typename?: 'AlertEdge', node?: { __typename?: 'Alert', id: string } | null } | null> | null } | null } | null } | null> | null, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null } } | null }; +export type FlowsQuery = { __typename?: 'RootQueryType', flows?: { __typename?: 'FlowConnection', edges?: Array<{ __typename?: 'FlowEdge', node?: { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, serviceCount?: number | null, componentCount?: number | null, alertCount?: number | null, pipelineCount?: number | null, pendingPipelineCount?: number | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, project?: { __typename?: 'Project', id: string, name: string } | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string } | null, serviceStatuses?: Array<{ __typename?: 'ServiceStatusCount', count: number, status: ServiceDeploymentStatus } | null> | null, componentStatuses?: Array<{ __typename?: 'ComponentStatusCount', state: ComponentState, count: number } | null> | null } | null } | null> | null, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null } } | null, flowServiceCounts?: Array<{ __typename?: 'ServiceStatusCount', count: number, status: ServiceDeploymentStatus } | null> | null }; export type FlowQueryVariables = Exact<{ id?: InputMaybe; @@ -20986,7 +21026,7 @@ export type FlowQueryVariables = Exact<{ }>; -export type FlowQuery = { __typename?: 'RootQueryType', flow?: { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, project?: { __typename?: 'Project', id: string, name: string } | null, alerts?: { __typename?: 'AlertConnection', edges?: Array<{ __typename?: 'AlertEdge', node?: { __typename?: 'Alert', id: string } | null } | null> | null } | null } | null }; +export type FlowQuery = { __typename?: 'RootQueryType', flow?: { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, serviceCount?: number | null, componentCount?: number | null, alertCount?: number | null, pipelineCount?: number | null, pendingPipelineCount?: number | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, project?: { __typename?: 'Project', id: string, name: string } | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string } | null, serviceStatuses?: Array<{ __typename?: 'ServiceStatusCount', count: number, status: ServiceDeploymentStatus } | null> | null, componentStatuses?: Array<{ __typename?: 'ComponentStatusCount', state: ComponentState, count: number } | null> | null } | null }; export type FlowServicesQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -21065,7 +21105,7 @@ export type UpsertFlowMutationVariables = Exact<{ }>; -export type UpsertFlowMutation = { __typename?: 'RootMutationType', upsertFlow?: { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, project?: { __typename?: 'Project', id: string, name: string } | null, alerts?: { __typename?: 'AlertConnection', edges?: Array<{ __typename?: 'AlertEdge', node?: { __typename?: 'Alert', id: string } | null } | null> | null } | null } | null }; +export type UpsertFlowMutation = { __typename?: 'RootMutationType', upsertFlow?: { __typename?: 'Flow', id: string, name: string, description?: string | null, icon?: string | null, metadata?: Record | null, repositories?: Array | null, serviceCount?: number | null, componentCount?: number | null, alertCount?: number | null, pipelineCount?: number | null, pendingPipelineCount?: number | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, project?: { __typename?: 'Project', id: string, name: string } | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string } | null, serviceStatuses?: Array<{ __typename?: 'ServiceStatusCount', count: number, status: ServiceDeploymentStatus } | null> | null, componentStatuses?: Array<{ __typename?: 'ComponentStatusCount', state: ComponentState, count: number } | null> | null } | null }; export type GroupMemberFragment = { __typename?: 'GroupMember', user?: { __typename?: 'User', id: string, pluralId?: string | null, name: string, email: string, profile?: string | null, backgroundColor?: string | null, readTimestamp?: string | null, homepage?: Homepage | null, emailSettings?: { __typename?: 'EmailSettings', digest?: boolean | null } | null, roles?: { __typename?: 'UserRoles', admin?: boolean | null } | null, personas?: Array<{ __typename?: 'Persona', id: string, name: string, description?: string | null, role?: PersonaRole | null, bindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'PersonaConfiguration', all?: boolean | null, deployments?: { __typename?: 'PersonaDeployment', addOns?: boolean | null, clusters?: boolean | null, pipelines?: boolean | null, providers?: boolean | null, repositories?: boolean | null, services?: boolean | null } | null, home?: { __typename?: 'PersonaHome', manager?: boolean | null, security?: boolean | null } | null, flows?: { __typename?: 'PersonaFlows', permissions?: boolean | null, startWorkbenchJob?: boolean | null, pipelines?: boolean | null, previews?: boolean | null, workbenches?: boolean | null } | null, sidebar?: { __typename?: 'PersonaSidebar', audits?: boolean | null, flows?: boolean | null, kubernetes?: boolean | null, pullRequests?: boolean | null, settings?: boolean | null, backups?: boolean | null, stacks?: boolean | null, workbenches?: boolean | null, security?: boolean | null, cost?: boolean | null, cd?: boolean | null, ai?: boolean | null } | null, services?: { __typename?: 'PersonaServices', configuration?: boolean | null, secrets?: boolean | null } | null, ai?: { __typename?: 'PersonaAi', pr?: boolean | null } | null } | null } | null> | null } | null, group?: { __typename?: 'Group', id: string, name: string, description?: string | null, global?: boolean | null, memberCount?: number | null, insertedAt?: string | null, updatedAt?: string | null } | null }; @@ -22791,7 +22831,7 @@ export type WorkbenchIssuesQueryVariables = Exact<{ providers?: InputMaybe> | InputMaybe>; statuses?: InputMaybe> | InputMaybe>; sort?: InputMaybe; - direction?: InputMaybe; + direction?: InputMaybe; first?: InputMaybe; after?: InputMaybe; }>; @@ -26362,12 +26402,6 @@ export const ServiceDeploymentBindingsFragmentDoc = gql` } } ${PolicyBindingFragmentDoc}`; -export const ServiceStatusCountFragmentDoc = gql` - fragment ServiceStatusCount on ServiceStatusCount { - count - status -} - `; export const ComponentTreeFragmentDoc = gql` fragment ComponentTree on ComponentTree { root { @@ -26545,6 +26579,12 @@ export const IsoImageFragmentDoc = gql` } } `; +export const ServiceStatusCountFragmentDoc = gql` + fragment ServiceStatusCount on ServiceStatusCount { + count + status +} + `; export const FlowBasicFragmentDoc = gql` fragment FlowBasic on Flow { id @@ -26557,15 +26597,23 @@ export const FlowBasicFragmentDoc = gql` id name } - alerts(first: 500) { - edges { - node { - id - } - } + agentRuntime { + id + } + serviceCount + componentCount + alertCount + pipelineCount + pendingPipelineCount + serviceStatuses { + ...ServiceStatusCount + } + componentStatuses { + state + count } } - `; + ${ServiceStatusCountFragmentDoc}`; export const FlowBasicWithBindingsFragmentDoc = gql` fragment FlowBasicWithBindings on Flow { ...FlowBasic @@ -38910,8 +38958,16 @@ export type ClusterIsoImagesLazyQueryHookResult = ReturnType; export type ClusterIsoImagesQueryResult = Apollo.QueryResult; export const FlowsDocument = gql` - query Flows($first: Int = 100, $after: String, $q: String) { - flows(first: $first, after: $after, q: $q) { + query Flows($first: Int = 100, $after: String, $q: String, $statuses: [ServiceDeploymentStatus], $sort: FlowSort, $direction: SortDirection, $favoriteIds: [ID]) { + flows( + first: $first + after: $after + q: $q + statuses: $statuses + sort: $sort + direction: $direction + favoriteIds: $favoriteIds + ) { edges { node { ...FlowBasicWithBindings @@ -38921,9 +38977,13 @@ export const FlowsDocument = gql` ...PageInfo } } + flowServiceCounts(q: $q) { + ...ServiceStatusCount + } } ${FlowBasicWithBindingsFragmentDoc} -${PageInfoFragmentDoc}`; +${PageInfoFragmentDoc} +${ServiceStatusCountFragmentDoc}`; /** * __useFlowsQuery__ @@ -38940,6 +39000,10 @@ ${PageInfoFragmentDoc}`; * first: // value for 'first' * after: // value for 'after' * q: // value for 'q' + * statuses: // value for 'statuses' + * sort: // value for 'sort' + * direction: // value for 'direction' + * favoriteIds: // value for 'favoriteIds' * }, * }); */ @@ -46961,7 +47025,7 @@ export type WorkbenchesIssuesLazyQueryHookResult = ReturnType; export type WorkbenchesIssuesQueryResult = Apollo.QueryResult; export const WorkbenchIssuesDocument = gql` - query WorkbenchIssues($id: ID!, $q: String, $providers: [IssueWebhookProvider], $statuses: [IssueStatus], $sort: IssueSort, $direction: IssueSortDirection, $first: Int = 100, $after: String) { + query WorkbenchIssues($id: ID!, $q: String, $providers: [IssueWebhookProvider], $statuses: [IssueStatus], $sort: IssueSort, $direction: SortDirection, $first: Int = 100, $after: String) { workbench(id: $id) { id issueCounts { diff --git a/js/console/src/generated/persisted-queries/client.json b/js/console/src/generated/persisted-queries/client.json index 5ee9082001..2139abb470 100644 --- a/js/console/src/generated/persisted-queries/client.json +++ b/js/console/src/generated/persisted-queries/client.json @@ -1107,15 +1107,15 @@ "name": "ClusterISOImages", "body": "query ClusterISOImages($after: String, $first: Int, $before: String, $last: Int) {\n clusterIsoImages(after: $after, first: $first, before: $before, last: $last) {\n pageInfo {\n ...PageInfo\n __typename\n }\n edges {\n node {\n ...IsoImage\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}\n\nfragment IsoImage on ClusterIsoImage {\n id\n user\n password\n registry\n image\n insertedAt\n project {\n name\n __typename\n }\n __typename\n}" }, - "sha256:0da1b3740548a59f4d01a578dc488574ff75cdba0b687a7c634b564b81bcaf74": { + "sha256:02d71c230a559504285494a9910807038081c34115993083d76114108297adef": { "type": "query", "name": "Flows", - "body": "query Flows($first: Int = 100, $after: String, $q: String) {\n flows(first: $first, after: $after, q: $q) {\n edges {\n node {\n ...FlowBasicWithBindings\n __typename\n }\n __typename\n }\n pageInfo {\n ...PageInfo\n __typename\n }\n __typename\n }\n}\n\nfragment FlowBasicWithBindings on Flow {\n ...FlowBasic\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment FlowBasic on Flow {\n id\n name\n description\n icon\n metadata\n repositories\n project {\n id\n name\n __typename\n }\n alerts(first: 500) {\n edges {\n node {\n id\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}" + "body": "query Flows($first: Int = 100, $after: String, $q: String, $statuses: [ServiceDeploymentStatus], $sort: FlowSort, $direction: SortDirection, $favoriteIds: [ID]) {\n flows(\n first: $first\n after: $after\n q: $q\n statuses: $statuses\n sort: $sort\n direction: $direction\n favoriteIds: $favoriteIds\n ) {\n edges {\n node {\n ...FlowBasicWithBindings\n __typename\n }\n __typename\n }\n pageInfo {\n ...PageInfo\n __typename\n }\n __typename\n }\n flowServiceCounts(q: $q) {\n ...ServiceStatusCount\n __typename\n }\n}\n\nfragment FlowBasicWithBindings on Flow {\n ...FlowBasic\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment FlowBasic on Flow {\n id\n name\n description\n icon\n metadata\n repositories\n project {\n id\n name\n __typename\n }\n agentRuntime {\n id\n __typename\n }\n serviceCount\n componentCount\n alertCount\n pipelineCount\n pendingPipelineCount\n serviceStatuses {\n ...ServiceStatusCount\n __typename\n }\n componentStatuses {\n state\n count\n __typename\n }\n __typename\n}\n\nfragment ServiceStatusCount on ServiceStatusCount {\n count\n status\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}" }, - "sha256:3ba6254e4a4708f6655e7c87e116dd73a4bad3998b4ee6f622dc1e08db88d586": { + "sha256:706c8712551d34fe5958125f118178b54b68309944986e1dc8cfb2b98e716dc8": { "type": "query", "name": "Flow", - "body": "query Flow($id: ID, $name: String) {\n flow(id: $id, name: $name) {\n ...FlowBasicWithBindings\n __typename\n }\n}\n\nfragment FlowBasicWithBindings on Flow {\n ...FlowBasic\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment FlowBasic on Flow {\n id\n name\n description\n icon\n metadata\n repositories\n project {\n id\n name\n __typename\n }\n alerts(first: 500) {\n edges {\n node {\n id\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + "body": "query Flow($id: ID, $name: String) {\n flow(id: $id, name: $name) {\n ...FlowBasicWithBindings\n __typename\n }\n}\n\nfragment FlowBasicWithBindings on Flow {\n ...FlowBasic\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment FlowBasic on Flow {\n id\n name\n description\n icon\n metadata\n repositories\n project {\n id\n name\n __typename\n }\n agentRuntime {\n id\n __typename\n }\n serviceCount\n componentCount\n alertCount\n pipelineCount\n pendingPipelineCount\n serviceStatuses {\n ...ServiceStatusCount\n __typename\n }\n componentStatuses {\n state\n count\n __typename\n }\n __typename\n}\n\nfragment ServiceStatusCount on ServiceStatusCount {\n count\n status\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" }, "sha256:8f11d641ea113fae6525d54255d5af1b257a433422418763c05e4b8376b7e9c7": { "type": "query", @@ -1157,10 +1157,10 @@ "name": "FlowVulnerabilityReports", "body": "query FlowVulnerabilityReports($id: ID!, $first: Int = 100, $after: String) {\n flow(id: $id) {\n id\n vulnerabilityReports(first: $first, after: $after) {\n ...VulnerabilityReportConnection\n __typename\n }\n __typename\n }\n}\n\nfragment VulnerabilityReportConnection on VulnerabilityReportConnection {\n pageInfo {\n ...PageInfo\n __typename\n }\n edges {\n node {\n ...VulnerabilityReportTiny\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}\n\nfragment VulnerabilityReportTiny on VulnerabilityReport {\n id\n artifactUrl\n services {\n service {\n id\n name\n cluster {\n id\n __typename\n }\n __typename\n }\n __typename\n }\n namespaces {\n namespace\n __typename\n }\n summary {\n criticalCount\n highCount\n mediumCount\n lowCount\n unknownCount\n noneCount\n __typename\n }\n artifactRepoUrl\n __typename\n}" }, - "sha256:98abe49796f5c9a4b65a17c6ae91b679f2cb93e9ec62eb2c69a2d00c443dbea0": { + "sha256:7219e3ff31912432eda2b53a84f651059595eb52f3b3a8df7a038681dd05b1a5": { "type": "mutation", "name": "UpsertFlow", - "body": "mutation UpsertFlow($attributes: FlowAttributes!) {\n upsertFlow(attributes: $attributes) {\n ...FlowBasicWithBindings\n __typename\n }\n}\n\nfragment FlowBasicWithBindings on Flow {\n ...FlowBasic\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment FlowBasic on Flow {\n id\n name\n description\n icon\n metadata\n repositories\n project {\n id\n name\n __typename\n }\n alerts(first: 500) {\n edges {\n node {\n id\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + "body": "mutation UpsertFlow($attributes: FlowAttributes!) {\n upsertFlow(attributes: $attributes) {\n ...FlowBasicWithBindings\n __typename\n }\n}\n\nfragment FlowBasicWithBindings on Flow {\n ...FlowBasic\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment FlowBasic on Flow {\n id\n name\n description\n icon\n metadata\n repositories\n project {\n id\n name\n __typename\n }\n agentRuntime {\n id\n __typename\n }\n serviceCount\n componentCount\n alertCount\n pipelineCount\n pendingPipelineCount\n serviceStatuses {\n ...ServiceStatusCount\n __typename\n }\n componentStatuses {\n state\n count\n __typename\n }\n __typename\n}\n\nfragment ServiceStatusCount on ServiceStatusCount {\n count\n status\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" }, "sha256:841c77fd0cc31881ed510897251ab52473e08a7e9aeffccb1a954ab43adda5fe": { "type": "query", @@ -2017,10 +2017,10 @@ "name": "WorkbenchesIssues", "body": "query WorkbenchesIssues($first: Int = 100, $after: String) {\n workbenchIssues(first: $first, after: $after) {\n pageInfo {\n ...PageInfo\n __typename\n }\n edges {\n node {\n ...WorkbenchIssue\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}\n\nfragment WorkbenchIssue on Issue {\n id\n title\n externalId\n provider\n status\n url\n insertedAt\n updatedAt\n workbench {\n id\n __typename\n }\n workbenchJob {\n id\n status\n __typename\n }\n __typename\n}" }, - "sha256:c57ef4bfc26cc95d38646a201732b67387ae61e04ce808fe86113a9e014abdb6": { + "sha256:afac65961d41ffe5b08d62509d6029afbb95e345843d80388971aea3bcd18d0b": { "type": "query", "name": "WorkbenchIssues", - "body": "query WorkbenchIssues($id: ID!, $q: String, $providers: [IssueWebhookProvider], $statuses: [IssueStatus], $sort: IssueSort, $direction: IssueSortDirection, $first: Int = 100, $after: String) {\n workbench(id: $id) {\n id\n issueCounts {\n providers {\n provider\n count\n __typename\n }\n statuses {\n status\n count\n __typename\n }\n __typename\n }\n issues(\n q: $q\n providers: $providers\n statuses: $statuses\n sort: $sort\n direction: $direction\n first: $first\n after: $after\n ) {\n pageInfo {\n ...PageInfo\n __typename\n }\n edges {\n node {\n ...WorkbenchIssue\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}\n\nfragment WorkbenchIssue on Issue {\n id\n title\n externalId\n provider\n status\n url\n insertedAt\n updatedAt\n workbench {\n id\n __typename\n }\n workbenchJob {\n id\n status\n __typename\n }\n __typename\n}" + "body": "query WorkbenchIssues($id: ID!, $q: String, $providers: [IssueWebhookProvider], $statuses: [IssueStatus], $sort: IssueSort, $direction: SortDirection, $first: Int = 100, $after: String) {\n workbench(id: $id) {\n id\n issueCounts {\n providers {\n provider\n count\n __typename\n }\n statuses {\n status\n count\n __typename\n }\n __typename\n }\n issues(\n q: $q\n providers: $providers\n statuses: $statuses\n sort: $sort\n direction: $direction\n first: $first\n after: $after\n ) {\n pageInfo {\n ...PageInfo\n __typename\n }\n edges {\n node {\n ...WorkbenchIssue\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}\n\nfragment WorkbenchIssue on Issue {\n id\n title\n externalId\n provider\n status\n url\n insertedAt\n updatedAt\n workbench {\n id\n __typename\n }\n workbenchJob {\n id\n status\n __typename\n }\n __typename\n}" }, "sha256:6aeee90304566782ac756dfae21ed1efa0d4a58ad4814a10a603f01d7fdfbc77": { "type": "mutation", diff --git a/js/console/src/graph/flow.graphql b/js/console/src/graph/flow.graphql index 694ccccafe..426b133a73 100644 --- a/js/console/src/graph/flow.graphql +++ b/js/console/src/graph/flow.graphql @@ -9,13 +9,20 @@ fragment FlowBasic on Flow { id name } - alerts(first: 500) { - # just to get the count - edges { - node { - id - } - } + agentRuntime { + id + } + serviceCount + componentCount + alertCount + pipelineCount + pendingPipelineCount + serviceStatuses { + ...ServiceStatusCount + } + componentStatuses { + state + count } } @@ -85,8 +92,24 @@ fragment PreviewEnvironmentTemplateConnection on PreviewEnvironmentTemplateConne } } -query Flows($first: Int = 100, $after: String, $q: String) { - flows(first: $first, after: $after, q: $q) { +query Flows( + $first: Int = 100 + $after: String + $q: String + $statuses: [ServiceDeploymentStatus] + $sort: FlowSort + $direction: SortDirection + $favoriteIds: [ID] +) { + flows( + first: $first + after: $after + q: $q + statuses: $statuses + sort: $sort + direction: $direction + favoriteIds: $favoriteIds + ) { edges { node { ...FlowBasicWithBindings @@ -96,6 +119,9 @@ query Flows($first: Int = 100, $after: String, $q: String) { ...PageInfo } } + flowServiceCounts(q: $q) { + ...ServiceStatusCount + } } query Flow($id: ID, $name: String) { diff --git a/js/console/src/graph/workbench.graphql b/js/console/src/graph/workbench.graphql index aaa15df07e..bfcfe78427 100644 --- a/js/console/src/graph/workbench.graphql +++ b/js/console/src/graph/workbench.graphql @@ -1016,7 +1016,7 @@ query WorkbenchIssues( $providers: [IssueWebhookProvider] $statuses: [IssueStatus] $sort: IssueSort - $direction: IssueSortDirection + $direction: SortDirection $first: Int = 100 $after: String ) { diff --git a/js/design-system/src/components/Chip.tsx b/js/design-system/src/components/Chip.tsx index 7b85928d4c..de20ef093f 100644 --- a/js/design-system/src/components/Chip.tsx +++ b/js/design-system/src/components/Chip.tsx @@ -30,6 +30,7 @@ export type ChipProps = ComponentPropsWithRef & { closeButton?: boolean closeButtonProps?: ComponentPropsWithRef<'div'> clickable?: boolean + rounded?: boolean truncateWidth?: number truncateEdge?: 'start' | 'end' tooltip?: boolean | ComponentProps['label'] @@ -74,6 +75,7 @@ const ChipCardSC = styled(Card)<{ $truncateWidth?: number $truncateEdge?: 'start' | 'end' $condensed?: boolean + $rounded?: boolean }>(({ $size, $severity, @@ -81,6 +83,7 @@ const ChipCardSC = styled(Card)<{ $truncateWidth, $truncateEdge, $condensed, + $rounded, theme, }) => { const textColor = $inactive @@ -108,6 +111,7 @@ const ChipCardSC = styled(Card)<{ gap: $condensed ? 6 : theme.spacing.xsmall, // Chips are dense inline labels — hairline only, no Card elevation shadow ...(theme.mode === 'light' && { boxShadow: 'none' }), + ...($rounded && { borderRadius: 999, boxShadow: 'none' }), }, '.children': { display: 'flex', @@ -188,6 +192,7 @@ function Chip({ disabled, tooltip, tooltipProps, + rounded = false, ...props }: ChipProps) { fillLevel = useDecideFillLevel({ fillLevel }) @@ -208,6 +213,7 @@ function Chip({ $severity={severity} $truncateWidth={truncateWidth} $truncateEdge={truncateEdge} + $rounded={rounded} {...props} > {loading && ( diff --git a/lib/console/graphql.ex b/lib/console/graphql.ex index 0072e74d29..b682ad9be3 100644 --- a/lib/console/graphql.ex +++ b/lib/console/graphql.ex @@ -12,6 +12,7 @@ defmodule Console.GraphQl do ClusterLoader, PolicyCountLoader, GroupMemberCountLoader, + FlowSummaryLoader, Deployments, AI } @@ -40,7 +41,8 @@ defmodule Console.GraphQl do PipelineGateLoader, ClusterLoader, PolicyCountLoader, - GroupMemberCountLoader + GroupMemberCountLoader, + FlowSummaryLoader ] def context(ctx) do diff --git a/lib/console/graphql/deployments/flow.ex b/lib/console/graphql/deployments/flow.ex index 183ffd4a3b..d67cdc64e0 100644 --- a/lib/console/graphql/deployments/flow.ex +++ b/lib/console/graphql/deployments/flow.ex @@ -1,10 +1,16 @@ defmodule Console.GraphQl.Deployments.Flow do use Console.GraphQl.Schema.Base alias Console.Middleware.AdminRequired - alias Console.GraphQl.Resolvers.{Deployments, User} + alias Console.GraphQl.Resolvers.{Deployments, User, FlowSummaryLoader} ecto_enum :mcp_server_protocol, Console.Schema.McpServer.Protocol + enum :flow_sort do + value :name + value :service_count + value :favorited + end + input_object :flow_attributes do field :name, non_null(:string) field :description, :string @@ -59,6 +65,11 @@ defmodule Console.GraphQl.Deployments.Flow do field :preview_ttl, :string, description: "how long preview environments should live, as a kubernetes duration (e.g. 1d, 5s)" end + object :component_status_count do + field :state, non_null(:component_state) + field :count, non_null(:integer) + end + object :flow do field :id, non_null(:id) field :name, non_null(:string) @@ -79,6 +90,27 @@ defmodule Console.GraphQl.Deployments.Flow do field :write_bindings, list_of(:policy_binding), resolve: dataloader(Deployments), description: "write policy for this flow" field :project, :project, resolve: dataloader(Deployments), description: "the project this flow belongs to" + field :service_count, :integer, + resolve: FlowSummaryLoader.resolve(:service_count), + description: "the number of services in this flow" + field :component_count, :integer, + resolve: FlowSummaryLoader.resolve(:component_count), + description: "the number of service components in this flow" + field :alert_count, :integer, + resolve: FlowSummaryLoader.resolve(:alert_count), + description: "the number of alerts for services in this flow" + field :pipeline_count, :integer, + resolve: FlowSummaryLoader.resolve(:pipeline_count), + description: "the number of pipelines in this flow" + field :pending_pipeline_count, :integer, + resolve: FlowSummaryLoader.resolve(:pending_pipeline_count), + description: "the number of pending pipeline gates in this flow" + field :service_statuses, list_of(:service_status_count), + resolve: FlowSummaryLoader.resolve(:service_statuses), + description: "a rollup of service statuses in this flow" + field :component_statuses, list_of(:component_status_count), + resolve: FlowSummaryLoader.resolve(:component_statuses), + description: "a rollup of component states in this flow" connection field :services, node_type: :service_deployment do resolve &Deployments.services_for_flow/3 end @@ -219,10 +251,26 @@ defmodule Console.GraphQl.Deployments.Flow do resource: :flow, action: :read arg :q, :string + arg :statuses, list_of(:service_deployment_status), + description: "return flows that have at least one service in one of these statuses" + arg :sort, :flow_sort, description: "field to sort flows by" + arg :direction, :sort_direction, description: "sort direction" + arg :favorite_ids, list_of(:id), + description: "flow ids to rank first when sorting by favorited" resolve &Deployments.list_flows/2 end + field :flow_service_counts, list_of(:service_status_count) do + middleware Authenticated + middleware Scope, + resource: :flow, + action: :read + arg :q, :string, description: "restrict counts to flows matching this search" + + resolve &Deployments.flow_service_counts/2 + end + field :flow, :flow do middleware Authenticated middleware Scope, diff --git a/lib/console/graphql/deployments/integration.ex b/lib/console/graphql/deployments/integration.ex index 8c8bf61826..7852fedb41 100644 --- a/lib/console/graphql/deployments/integration.ex +++ b/lib/console/graphql/deployments/integration.ex @@ -12,11 +12,6 @@ defmodule Console.GraphQl.Deployments.Integration do value :title end - enum :issue_sort_direction do - value :asc - value :desc - end - @desc "A chat connection is a way to connect Plural to a chat platform like Slack or Microsoft Teams" input_object :chat_provider_connection_attributes do field :name, non_null(:string), description: "the name of this chat connection" diff --git a/lib/console/graphql/deployments/workbench.ex b/lib/console/graphql/deployments/workbench.ex index f1b2c00349..e512324ec6 100644 --- a/lib/console/graphql/deployments/workbench.ex +++ b/lib/console/graphql/deployments/workbench.ex @@ -564,7 +564,7 @@ defmodule Console.GraphQl.Deployments.Workbench do arg :providers, list_of(:issue_webhook_provider), description: "filter issues by provider" arg :statuses, list_of(:issue_status), description: "filter issues by status" arg :sort, :issue_sort, description: "field to sort issues by" - arg :direction, :issue_sort_direction, description: "sort direction" + arg :direction, :sort_direction, description: "sort direction" resolve &Deployments.list_issues/3 end diff --git a/lib/console/graphql/resolvers/dataloader.ex b/lib/console/graphql/resolvers/dataloader.ex index 4746334008..69c57630ea 100644 --- a/lib/console/graphql/resolvers/dataloader.ex +++ b/lib/console/graphql/resolvers/dataloader.ex @@ -119,3 +119,68 @@ defmodule Console.GraphQl.Resolvers.GroupMemberCountLoader do Map.new(ids, & {&1, Map.get(counts, &1, 0)}) end end + +defmodule Console.GraphQl.Resolvers.FlowSummaryLoader do + import Absinthe.Resolution.Helpers, only: [on_load: 2] + alias Console.Repo + alias Console.Schema.{Service, ServiceComponent, Alert, Pipeline} + + def data(_) do + Dataloader.KV.new(&query/2, max_concurrency: 1) + end + + def query(:summary, ids) do + ids = MapSet.to_list(ids) + summaries = summaries(ids) + Map.new(ids, & {&1, Map.get(summaries, &1, empty_summary())}) + end + + def resolve(key) do + fn %{id: id}, _, %{context: %{loader: loader}} -> + loader + |> Dataloader.load(__MODULE__, :summary, id) + |> on_load(fn loader -> + {:ok, Map.get(Dataloader.get(loader, __MODULE__, :summary, id), key)} + end) + end + end + + defp summaries(ids) do + base = Map.new(ids, &{&1, empty_summary()}) + + base + |> put_status_groups(Repo.all(Service.for_flow_ids(ids) |> Service.count_by_flow_status()), :service_statuses, :service_count) + |> put_status_groups(Repo.all(ServiceComponent.count_by_flow_state(ids)), :component_statuses, :component_count) + |> put_counts(Repo.all(Alert.count_by_flow(ids)), :alert_count) + |> put_counts(Repo.all(Pipeline.for_flow_ids(ids) |> Pipeline.count_by_flow()), :pipeline_count) + |> put_counts(Repo.all(Pipeline.for_flow_ids(ids) |> Pipeline.pending_gate_count_by_flow()), :pending_pipeline_count) + end + + defp empty_summary do + %{ + service_count: 0, + component_count: 0, + alert_count: 0, + pipeline_count: 0, + pending_pipeline_count: 0, + service_statuses: [], + component_statuses: [] + } + end + + defp put_status_groups(map, rows, list_key, count_key) do + Enum.reduce(rows, map, fn {id, entry}, acc -> + Map.update!(acc, id, fn summary -> + summary + |> Map.update!(list_key, &[entry | &1]) + |> Map.update!(count_key, &(&1 + entry.count)) + end) + end) + end + + defp put_counts(map, rows, key) do + Enum.reduce(rows, map, fn {id, count}, acc -> + Map.update!(acc, id, &Map.put(&1, key, count)) + end) + end +end diff --git a/lib/console/graphql/resolvers/deployments/flow.ex b/lib/console/graphql/resolvers/deployments/flow.ex index aae4155656..f16409286d 100644 --- a/lib/console/graphql/resolvers/deployments/flow.ex +++ b/lib/console/graphql/resolvers/deployments/flow.ex @@ -1,5 +1,6 @@ defmodule Console.GraphQl.Resolvers.Deployments.Flow do use Console.GraphQl.Resolvers.Deployments.Base + alias Console.Repo alias Console.Deployments.{Flows, Policies} alias Console.Schema.{ Flow, @@ -16,12 +17,40 @@ defmodule Console.GraphQl.Resolvers.Deployments.Flow do } def list_flows(args, %{context: %{current_user: user}}) do - Flow.ordered() - |> Flow.for_user(user) + Flow.for_user(user) |> maybe_search(Flow, args) + |> flow_status_filter(args) + |> Flow.by_ids() + |> flow_order(args) |> paginate(args) end + defp flow_status_filter(query, %{statuses: statuses}) when is_list(statuses), + do: Flow.with_service_statuses(query, statuses) + defp flow_status_filter(query, _), do: query + + defp flow_order(query, args) do + dir = Map.get(args, :direction) || :asc + apply_flow_sort(query, Map.get(args, :sort), dir, args) + end + + defp apply_flow_sort(query, :service_count, dir, _), + do: Flow.ordered_by_service_count(query, dir) + defp apply_flow_sort(query, :favorited, dir, args), + do: Flow.ordered_by_favorites(query, Map.get(args, :favorite_ids) || [], dir) + defp apply_flow_sort(query, _, dir, _), + do: Flow.ordered(query, [{dir, :name}]) + + def flow_service_counts(args, %{context: %{current_user: user}}) do + Flow.for_user(user) + |> maybe_search(Flow, args) + |> Flow.ids() + |> Service.for_flows() + |> Service.statuses() + |> Repo.all() + |> ok() + end + def list_mcp_servers(args, %{context: %{current_user: user}}) do McpServer.ordered() |> McpServer.for_user(user) diff --git a/lib/console/graphql/schema/base.ex b/lib/console/graphql/schema/base.ex index b3ec44fb97..7f1f4c80ee 100644 --- a/lib/console/graphql/schema/base.ex +++ b/lib/console/graphql/schema/base.ex @@ -14,6 +14,11 @@ defmodule Console.GraphQl.Schema.Base do value :after end + enum :sort_direction do + value :asc + value :desc + end + object :metric_result do field :timestamp, :long, resolve: fn %{timestamp: ts}, _, _ -> {:ok, ceil(ts)} end field :value, :string diff --git a/lib/console/schema/alert.ex b/lib/console/schema/alert.ex index dc3bcc3c51..1c0bf1d95a 100644 --- a/lib/console/schema/alert.ex +++ b/lib/console/schema/alert.ex @@ -120,6 +120,15 @@ defmodule Console.Schema.Alert do ) end + def count_by_flow(ids) do + from(a in __MODULE__, + join: s in assoc(a, :service), + where: s.flow_id in ^ids, + group_by: s.flow_id, + select: {s.flow_id, count(a.id)} + ) + end + def distinct(query \\ __MODULE__) do from(a in query, distinct: true) end diff --git a/lib/console/schema/flow.ex b/lib/console/schema/flow.ex index 7bf0619ac9..2b0dfb69f6 100644 --- a/lib/console/schema/flow.ex +++ b/lib/console/schema/flow.ex @@ -6,7 +6,8 @@ defmodule Console.Schema.Flow do User, McpServerAssociation, AgentRuntime, - FlowWorkbench + FlowWorkbench, + Service } alias Console.Deployments.Policies.Rbac @@ -66,10 +67,50 @@ defmodule Console.Schema.Flow do from(f in query, order_by: [asc: :id]) end + def ids(query \\ __MODULE__) do + from(f in query, select: f.id) + end + + def by_ids(query) do + from(f in __MODULE__, where: f.id in subquery(ids(query))) + end + def ordered(query \\ __MODULE__, order \\ [asc: :name]) do from(f in query, order_by: ^order) end + def ordered_by_service_count(query \\ __MODULE__, dir \\ :desc) do + counts = + from(s in Service, + group_by: s.flow_id, + select: %{flow_id: s.flow_id, n: count(s.id)} + ) + + from(f in query, + left_join: c in subquery(counts), + on: c.flow_id == f.id, + order_by: [{^dir, coalesce(c.n, 0)}, {^dir, f.name}] + ) + end + + def ordered_by_favorites(query, [], dir), do: ordered(query, [{dir, :name}]) + def ordered_by_favorites(query, ids, dir) do + from(f in query, + order_by: [ + {:asc, fragment("CASE WHEN ? THEN 0 ELSE 1 END", f.id in ^ids)}, + {^dir, f.name} + ] + ) + end + + def with_service_statuses(query \\ __MODULE__, statuses) + def with_service_statuses(query, statuses) when statuses in [nil], do: query + def with_service_statuses(query, []), do: from(f in query, where: f.id in ^[]) + def with_service_statuses(query, statuses) do + ids = Service.for_statuses(statuses) |> Service.flow_ids() + from(f in query, where: f.id in subquery(ids)) + end + def changeset(model, attrs \\ %{}) do model |> cast(attrs, ~w(name description icon repositories project_id agent_runtime_id metadata max_previews)a) diff --git a/lib/console/schema/pipeline.ex b/lib/console/schema/pipeline.ex index d38120be45..7204b05244 100644 --- a/lib/console/schema/pipeline.ex +++ b/lib/console/schema/pipeline.ex @@ -33,6 +33,27 @@ defmodule Console.Schema.Pipeline do from(p in query, where: p.flow_id == ^flow_id) end + def for_flow_ids(query \\ __MODULE__, ids) do + from(p in query, where: p.flow_id in ^ids) + end + + def count_by_flow(query \\ __MODULE__) do + from(p in query, + group_by: p.flow_id, + select: {p.flow_id, count(p.id)} + ) + end + + def pending_gate_count_by_flow(query \\ __MODULE__) do + from(p in query, + join: e in assoc(p, :edges), + join: g in assoc(e, :gates), + where: g.state == :pending, + group_by: p.flow_id, + select: {p.flow_id, count(g.id)} + ) + end + def search(query \\ __MODULE__, q) do from(p in query, where: ilike(p.name, ^"%#{q}%")) end diff --git a/lib/console/schema/service.ex b/lib/console/schema/service.ex index 726547c558..328b845b5e 100644 --- a/lib/console/schema/service.ex +++ b/lib/console/schema/service.ex @@ -268,6 +268,26 @@ defmodule Console.Schema.Service do from(s in query, where: s.flow_id == ^flow_id) end + def for_flow_ids(query \\ __MODULE__, ids) do + from(s in query, where: s.flow_id in ^ids) + end + + def for_flows(flow_ids) do + from(s in __MODULE__, where: s.flow_id in subquery(flow_ids)) + end + + def flow_ids(query \\ __MODULE__) do + from(s in query, select: s.flow_id) + end + + def count_by_flow_status(query \\ __MODULE__) do + from(s in query, + group_by: [s.flow_id, s.status], + select: {s.flow_id, %{status: s.status, count: count(s.id)}} + ) + end + + def search(query \\ __MODULE__, sq) do from(s in query, where: ilike(s.name, ^"%#{sq}%")) end diff --git a/lib/console/schema/service_component.ex b/lib/console/schema/service_component.ex index cb23b7c731..0884fa5d41 100644 --- a/lib/console/schema/service_component.ex +++ b/lib/console/schema/service_component.ex @@ -43,6 +43,16 @@ defmodule Console.Schema.ServiceComponent do from(sc in query, where: sc.service_id == ^service_id) end + def count_by_flow_state(ids) do + from(sc in __MODULE__, + join: s in assoc(sc, :service), + where: s.flow_id in ^ids, + group_by: [s.flow_id, sc.state], + select: {s.flow_id, %{state: sc.state, count: count(sc.id)}} + ) + end + + def for_group(query, nil), do: from(sc in query, where: is_nil(sc.group)) def for_group(query, group), do: from(sc in query, where: sc.group == ^group) diff --git a/schema/schema.graphql b/schema/schema.graphql index 39ac205b81..0f92324da2 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -533,7 +533,34 @@ type RootQueryType { federatedCredential(id: ID!): FederatedCredential - flows(after: String, first: Int, before: String, last: Int, q: String): FlowConnection + flows( + after: String + + first: Int + + before: String + + last: Int + + q: String + + "return flows that have at least one service in one of these statuses" + statuses: [ServiceDeploymentStatus] + + "field to sort flows by" + sort: FlowSort + + "sort direction" + direction: SortDirection + + "flow ids to rank first when sorting by favorited" + favoriteIds: [ID] + ): FlowConnection + + flowServiceCounts( + "restrict counts to flows matching this search" + q: String + ): [ServiceStatusCount] flow(id: ID, name: String): Flow @@ -1856,11 +1883,6 @@ enum IssueSort { TITLE } -enum IssueSortDirection { - ASC - DESC -} - "A chat connection is a way to connect Plural to a chat platform like Slack or Microsoft Teams" input ChatProviderConnectionAttributes { "the name of this chat connection" @@ -4016,7 +4038,7 @@ type Workbench { sort: IssueSort "sort direction" - direction: IssueSortDirection + direction: SortDirection ): IssueConnection issueCounts: WorkbenchIssueCounts @@ -6225,6 +6247,12 @@ enum McpServerProtocol { STREAMABLE_HTTP } +enum FlowSort { + NAME + SERVICE_COUNT + FAVORITED +} + input FlowAttributes { name: String! @@ -6317,6 +6345,11 @@ input PreviewEnvironmentTemplateAttributes { previewTtl: String } +type ComponentStatusCount { + state: ComponentState! + count: Int! +} + type Flow { id: ID! @@ -6352,6 +6385,27 @@ type Flow { "the project this flow belongs to" project: Project + "the number of services in this flow" + serviceCount: Int + + "the number of service components in this flow" + componentCount: Int + + "the number of alerts for services in this flow" + alertCount: Int + + "the number of pipelines in this flow" + pipelineCount: Int + + "the number of pending pipeline gates in this flow" + pendingPipelineCount: Int + + "a rollup of service statuses in this flow" + serviceStatuses: [ServiceStatusCount] + + "a rollup of component states in this flow" + componentStatuses: [ComponentStatusCount] + services(after: String, first: Int, before: String, last: Int): ServiceDeploymentConnection pipelines(after: String, first: Int, before: String, last: Int): PipelineConnection @@ -17808,6 +17862,11 @@ enum Delta { DELETE } +enum SortDirection { + ASC + DESC +} + type MetricResult { timestamp: Long value: String diff --git a/test/console/graphql/queries/deployments/flow_queries_test.exs b/test/console/graphql/queries/deployments/flow_queries_test.exs index cd335ecc82..ae15edd03e 100644 --- a/test/console/graphql/queries/deployments/flow_queries_test.exs +++ b/test/console/graphql/queries/deployments/flow_queries_test.exs @@ -20,6 +20,109 @@ defmodule Console.GraphQl.Deployments.FlowQueriesTest do assert from_connection(found) |> ids_equal([flow1, flow2]) end + + test "it can roll up flow summaries and filter by service status" do + user = insert(:user) + healthy_flow = insert(:flow, name: "healthy-flow", read_bindings: [%{user_id: user.id}]) + failed_flow = insert(:flow, name: "failed-flow", read_bindings: [%{user_id: user.id}]) + healthy_svc = insert(:service, flow: healthy_flow, status: :healthy) + failed_svc = insert(:service, flow: failed_flow, status: :failed) + insert(:service, flow: healthy_flow, status: :stale) + insert(:service_component, service: healthy_svc, state: :running) + insert(:service_component, service: failed_svc, state: :failed) + insert(:alert, service: failed_svc) + pipe = insert(:pipeline, flow: failed_flow) + edge = insert(:pipeline_edge, pipeline: pipe) + insert(:pipeline_gate, edge: edge, state: :pending) + + {:ok, %{data: %{"flows" => found, "flowServiceCounts" => counts}}} = run_query(""" + query { + flows(first: 5) { + edges { + node { + id + serviceCount + componentCount + alertCount + pipelineCount + pendingPipelineCount + serviceStatuses { status count } + componentStatuses { state count } + } + } + } + flowServiceCounts { status count } + } + """, %{}, %{current_user: user}) + + nodes = Map.new(from_connection(found), & {&1["id"], &1}) + healthy = nodes[healthy_flow.id] + failed = nodes[failed_flow.id] + + assert healthy["serviceCount"] == 2 + assert healthy["componentCount"] == 1 + assert healthy["alertCount"] == 0 + assert failed["serviceCount"] == 1 + assert failed["alertCount"] == 1 + assert failed["pipelineCount"] == 1 + assert failed["pendingPipelineCount"] == 1 + assert Enum.any?(healthy["serviceStatuses"], & &1["status"] == "HEALTHY" && &1["count"] == 1) + assert Enum.any?(failed["componentStatuses"], & &1["state"] == "FAILED" && &1["count"] == 1) + assert Enum.any?(counts, & &1["status"] == "FAILED" && &1["count"] == 1) + + {:ok, %{data: %{"flows" => filtered}}} = run_query(""" + query { + flows(first: 5, statuses: [FAILED]) { + edges { node { id } } + } + } + """, %{}, %{current_user: user}) + + assert from_connection(filtered) + |> ids_equal([failed_flow]) + end + + test "it can sort flows by name, service count, and favorites" do + user = insert(:user) + alpha = insert(:flow, name: "alpha-flow", read_bindings: [%{user_id: user.id}]) + zeta = insert(:flow, name: "zeta-flow", read_bindings: [%{user_id: user.id}]) + insert(:service, flow: alpha) + insert_list(3, :service, flow: zeta) + + {:ok, %{data: %{"flows" => by_name}}} = run_query(""" + query { + flows(first: 5, sort: NAME, direction: DESC) { + edges { node { id } } + } + } + """, %{}, %{current_user: user}) + + assert from_connection(by_name) + |> Enum.map(& &1["id"]) == [zeta.id, alpha.id] + + {:ok, %{data: %{"flows" => by_count}}} = run_query(""" + query { + flows(first: 5, sort: SERVICE_COUNT, direction: DESC) { + edges { node { id } } + } + } + """, %{}, %{current_user: user}) + + assert from_connection(by_count) + |> Enum.map(& &1["id"]) == [zeta.id, alpha.id] + + {:ok, %{data: %{"flows" => by_favorite}}} = run_query(""" + query Flows($favoriteIds: [ID]) { + flows(first: 5, sort: FAVORITED, direction: ASC, favoriteIds: $favoriteIds) { + edges { node { id } } + } + } + """, %{"favoriteIds" => [alpha.id]}, %{current_user: user}) + + assert from_connection(by_favorite) + |> Enum.map(& &1["id"]) == [alpha.id, zeta.id] + end + end describe "flow" do