Skip to content

Commit f2d602e

Browse files
committed
added filtering to services
1 parent a7028a7 commit f2d602e

File tree

4 files changed

+188
-8
lines changed

4 files changed

+188
-8
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { createComponent, Shade } from '@furystack/shades'
2+
import {
3+
Button,
4+
cssVariableTheme,
5+
Icon,
6+
icons,
7+
NotyService,
8+
ToggleButton,
9+
ToggleButtonGroup,
10+
} from '@furystack/shades-common-components'
11+
import type { ServiceView } from 'common'
12+
13+
import { ServicesApiClient } from '../services/api-clients/services-api-client.js'
14+
import type { ServiceSummaryStatus } from '../utils/service-pipeline.js'
15+
16+
type ServiceFiltersProps = {
17+
filteredServices: ServiceView[]
18+
searchText: string
19+
onSearchTextChange: (text: string) => void
20+
statusFilter: string
21+
onStatusFilterChange: (value: string) => void
22+
}
23+
24+
const statusOptions: Array<{ value: ServiceSummaryStatus; label: string }> = [
25+
{ value: 'running', label: 'Running' },
26+
{ value: 'in-progress', label: 'In Progress' },
27+
{ value: 'error', label: 'Error' },
28+
{ value: 'pending', label: 'Pending' },
29+
]
30+
31+
export const ServiceFilters = Shade<ServiceFiltersProps>({
32+
customElementName: 'shade-service-filters',
33+
render: ({ props, injector, useState }) => {
34+
const api = injector.getInstance(ServicesApiClient)
35+
const noty = injector.getInstance(NotyService)
36+
const [isUpdateLoading, setIsUpdateLoading] = useState('isUpdateLoading', false)
37+
38+
const updatableServices = props.filteredServices.filter(
39+
(s) => s.cloneStatus === 'cloned' && s.commitsBehind && s.commitsBehind > 0,
40+
)
41+
42+
const updateAll = async () => {
43+
setIsUpdateLoading(true)
44+
const failures: string[] = []
45+
for (const svc of updatableServices) {
46+
try {
47+
await api.call({
48+
method: 'POST',
49+
action: '/services/:id/update' as '/services/:id/start',
50+
url: { id: svc.id },
51+
})
52+
} catch {
53+
failures.push(svc.displayName)
54+
}
55+
}
56+
if (failures.length > 0) {
57+
noty.emit('onNotyAdded', {
58+
title: 'Update failed',
59+
body: `Failed for: ${failures.join(', ')}`,
60+
type: 'error',
61+
})
62+
}
63+
setIsUpdateLoading(false)
64+
}
65+
66+
return (
67+
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
68+
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
69+
<Icon
70+
icon={icons.search}
71+
size={14}
72+
style={{
73+
position: 'absolute',
74+
left: '8px',
75+
pointerEvents: 'none',
76+
opacity: '0.5',
77+
}}
78+
/>
79+
<input
80+
type="text"
81+
placeholder="Search..."
82+
value={props.searchText}
83+
oninput={(e: Event) => props.onSearchTextChange((e.target as HTMLInputElement).value)}
84+
style={{
85+
padding: '4px 8px 4px 28px',
86+
borderRadius: cssVariableTheme.shape.borderRadius.sm,
87+
border: `1px solid ${cssVariableTheme.divider}`,
88+
background: cssVariableTheme.background.default,
89+
color: cssVariableTheme.text.primary,
90+
fontSize: cssVariableTheme.typography.fontSize.sm,
91+
width: '160px',
92+
outline: 'none',
93+
fontFamily: cssVariableTheme.typography.fontFamily,
94+
}}
95+
/>
96+
</div>
97+
98+
<ToggleButtonGroup
99+
exclusive
100+
size="small"
101+
value={props.statusFilter}
102+
onValueChange={(value) => props.onStatusFilterChange(typeof value === 'string' ? value : '')}
103+
>
104+
{statusOptions.map((opt) => (
105+
<ToggleButton value={opt.value}>{opt.label}</ToggleButton>
106+
))}
107+
</ToggleButtonGroup>
108+
109+
{updatableServices.length > 0 ? (
110+
<Button
111+
variant="outlined"
112+
size="small"
113+
color="primary"
114+
loading={isUpdateLoading}
115+
onclick={() => void updateAll()}
116+
startIcon={<Icon icon={icons.download} size="small" />}
117+
>
118+
{`Update All (${updatableServices.length})`}
119+
</Button>
120+
) : null}
121+
</div>
122+
)
123+
},
124+
})

frontend/src/components/service-table.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { FindOptions } from '@furystack/core'
22
import { createComponent, LocationService, Shade } from '@furystack/shades'
3-
import type { ColumnFilterConfig } from '@furystack/shades-common-components'
43
import {
54
Button,
65
type CollectionService,
@@ -29,10 +28,6 @@ type ServiceTableProps = {
2928

3029
type ServiceColumn = 'selection' | 'displayName' | 'pipeline' | 'branch' | 'actions'
3130

32-
const columnFilters: { [K in ServiceColumn]?: ColumnFilterConfig } = {
33-
displayName: { type: 'string' },
34-
}
35-
3631
export const ServiceTable = Shade<ServiceTableProps>({
3732
customElementName: 'shade-service-table',
3833
render: (options) => {
@@ -86,7 +81,6 @@ export const ServiceTable = Shade<ServiceTableProps>({
8681
onFindOptionsChange={setFindOptions}
8782
styles={undefined}
8883
collectionService={collectionService}
89-
columnFilters={columnFilters}
9084
headerComponents={{
9185
selection: () => <span />,
9286
displayName: () => <span>Service</span>,

frontend/src/pages/services/services-list.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {
2222

2323
import { StackCraftNestedRouteLink } from '../../components/app-routes.js'
2424
import { BulkActionBar } from '../../components/bulk-action-bar.js'
25+
import { ServiceFilters } from '../../components/service-filters.js'
2526
import { ServiceTable } from '../../components/service-table.js'
27+
import { getServiceSummaryStatus } from '../../utils/service-pipeline.js'
2628

2729
type ServicesListProps = {
2830
stackName: string
@@ -77,6 +79,34 @@ export const ServicesList = Shade<ServicesListProps>({
7779

7880
const isLoading = servicesState.status === 'connecting'
7981

82+
const [searchText, setSearchText] = options.useState('searchText', '')
83+
const [statusFilter, setStatusFilter] = options.useState('statusFilter', '')
84+
85+
const filteredServices = services.filter((s) => {
86+
if (searchText) {
87+
const term = searchText.toLowerCase()
88+
const matchesText =
89+
s.displayName.toLowerCase().includes(term) ||
90+
(s.description?.toLowerCase().includes(term) ?? false) ||
91+
(s.currentBranch?.toLowerCase().includes(term) ?? false)
92+
if (!matchesText) return false
93+
}
94+
if (statusFilter) {
95+
if (getServiceSummaryStatus(s) !== statusFilter) return false
96+
}
97+
return true
98+
})
99+
100+
const isFiltered = searchText !== '' || statusFilter !== ''
101+
const title = isFiltered
102+
? `Services (${filteredServices.length} / ${services.length})`
103+
: `Services (${services.length})`
104+
105+
const clearFilters = () => {
106+
setSearchText('')
107+
setStatusFilter('')
108+
}
109+
80110
if (isLoading) {
81111
return (
82112
<PageContainer>
@@ -91,9 +121,18 @@ export const ServicesList = Shade<ServicesListProps>({
91121
<PageContainer>
92122
<PageHeader
93123
icon={<Icon icon={icons.code} />}
94-
title={`Services (${services.length})`}
124+
title={title}
95125
actions={
96126
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
127+
{services.length > 0 ? (
128+
<ServiceFilters
129+
filteredServices={filteredServices}
130+
searchText={searchText}
131+
onSearchTextChange={setSearchText}
132+
statusFilter={statusFilter}
133+
onStatusFilterChange={setStatusFilter}
134+
/>
135+
) : null}
97136
<BulkActionBar collectionService={collectionService} />
98137
<StackCraftNestedRouteLink
99138
href="/stacks/:stackName/services/wizard"
@@ -120,8 +159,17 @@ export const ServicesList = Shade<ServicesListProps>({
120159
</StackCraftNestedRouteLink>
121160
</div>
122161
</div>
162+
) : filteredServices.length === 0 ? (
163+
<div style={{ textAlign: 'center', padding: '32px', opacity: '0.7' }}>
164+
No matching services.
165+
<div style={{ marginTop: '12px' }}>
166+
<Button variant="outlined" size="small" onclick={clearFilters}>
167+
Clear Filters
168+
</Button>
169+
</div>
170+
</div>
123171
) : (
124-
<ServiceTable services={services} collectionService={collectionService} />
172+
<ServiceTable services={filteredServices} collectionService={collectionService} />
125173
)}
126174
</PageContainer>
127175
)

frontend/src/utils/service-pipeline.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,20 @@ export const getSecondaryActions = (service: ServiceView): ServiceAction[] => {
208208
return actions
209209
}
210210

211+
export type ServiceSummaryStatus = 'error' | 'in-progress' | 'running' | 'pending'
212+
213+
/**
214+
* Derives a single summary status for a service from all its pipeline stages.
215+
* Priority: error > in-progress > running > pending.
216+
*/
217+
export const getServiceSummaryStatus = (service: ServiceView): ServiceSummaryStatus => {
218+
const stages = getPipelineStages(service)
219+
if (stages.some((s) => s.status === 'failed')) return 'error'
220+
if (stages.some((s) => s.status === 'in-progress')) return 'in-progress'
221+
if (service.runStatus === 'running') return 'running'
222+
return 'pending'
223+
}
224+
211225
export const needsSetup = (service: ServiceView): boolean => {
212226
if (service.repositoryId && service.cloneStatus !== 'cloned') return true
213227
if (service.installCommand && service.installStatus !== 'installed') return true

0 commit comments

Comments
 (0)