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
+ }
+ >
+ Manage MCP servers
+
- }
- 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 (
+ }
+ onClick={onClick}
+ >
+
+ Display
+ {showDot && }
+
+
+ )
+}
+
+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)}
/>
- }
+ setDisplayOpen(!displayOpen)}
- >
-
- Display
- {hasUncheckedIssueFilters(display) && }
-
-
-
-
-
+ />
+
+
+
{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