diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index df7a7ad231e59..1e5f0d431e96c 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "sync" "testing" "time" @@ -1720,19 +1721,32 @@ func TestExecutorAutostartSkipsWhenNoProvisionersAvailable(t *testing.T) { // Stop the workspace while provisioner is available workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) - // Wait for provisioner to be available for this specific workspace - coderdtest.MustWaitForProvisionersAvailable(t, db, workspace) p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, provisionerDaemonTags) require.NoError(t, err, "Error getting provisioner for workspace") - daemon1Closer.Close() + var wg sync.WaitGroup + wg.Add(2) - // Ensure the provisioner is stale - staleTime := sched.Next(workspace.LatestBuild.CreatedAt).Add((-1 * provisionerdserver.StaleInterval) + -10*time.Second) - coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, staleTime) + next := sched.Next(workspace.LatestBuild.CreatedAt) + go func() { + defer wg.Done() + // Ensure the provisioner is stale + staleTime := next.Add(-(provisionerdserver.StaleInterval * 2)) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, staleTime) + p, err = coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, provisionerDaemonTags) + assert.NoError(t, err, "Error getting provisioner for workspace") + assert.Eventually(t, func() bool { return p.LastSeenAt.Time.UnixNano() == staleTime.UnixNano() }, testutil.WaitMedium, testutil.IntervalFast) + }() - // Trigger autobuild - tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) + go func() { + defer wg.Done() + // Ensure the provisioner is gone or stale before triggering the autobuild + coderdtest.MustWaitForProvisionersUnavailable(t, db, workspace, provisionerDaemonTags, next) + // Trigger autobuild + tickCh <- next + }() + + wg.Wait() stats := <-statsCh @@ -1758,5 +1772,5 @@ func TestExecutorAutostartSkipsWhenNoProvisionersAvailable(t *testing.T) { }() stats = <-statsCh - assert.Len(t, stats.Transitions, 1, "should not create builds when no provisioners available") + assert.Len(t, stats.Transitions, 1, "should create builds when provisioners are available") } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index f773053c3a56c..b6aafc53daffa 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -1649,19 +1649,48 @@ func UpdateProvisionerLastSeenAt(t *testing.T, db database.Store, id uuid.UUID, func MustWaitForAnyProvisioner(t *testing.T, db database.Store) { t.Helper() ctx := ctxWithProvisionerPermissions(testutil.Context(t, testutil.WaitShort)) - require.Eventually(t, func() bool { + // testutil.Eventually(t, func) + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { daemons, err := db.GetProvisionerDaemons(ctx) return err == nil && len(daemons) > 0 - }, testutil.WaitShort, testutil.IntervalFast) + }, testutil.IntervalFast, "no provisioner daemons found") +} + +// MustWaitForProvisionersUnavailable waits for provisioners to become unavailable for a specific workspace +func MustWaitForProvisionersUnavailable(t *testing.T, db database.Store, workspace codersdk.Workspace, tags map[string]string, checkTime time.Time) { + t.Helper() + ctx := ctxWithProvisionerPermissions(testutil.Context(t, testutil.WaitMedium)) + + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { + // Use the same logic as hasValidProvisioner but expect false + provisionerDaemons, err := db.GetProvisionerDaemonsByOrganization(ctx, database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: workspace.OrganizationID, + WantTags: tags, + }) + if err != nil { + return false + } + + // Check if NO provisioners are active (all are stale or gone) + for _, pd := range provisionerDaemons { + if pd.LastSeenAt.Valid { + age := checkTime.Sub(pd.LastSeenAt.Time) + if age <= provisionerdserver.StaleInterval { + return false // Found an active provisioner, keep waiting + } + } + } + return true // No active provisioners found + }, testutil.IntervalFast, "there are still provisioners available for workspace, expected none") } // MustWaitForProvisionersAvailable waits for provisioners to be available for a specific workspace. -func MustWaitForProvisionersAvailable(t *testing.T, db database.Store, workspace codersdk.Workspace) uuid.UUID { +func MustWaitForProvisionersAvailable(t *testing.T, db database.Store, workspace codersdk.Workspace, ts time.Time) uuid.UUID { t.Helper() - ctx := ctxWithProvisionerPermissions(testutil.Context(t, testutil.WaitShort)) + ctx := ctxWithProvisionerPermissions(testutil.Context(t, testutil.WaitLong)) id := uuid.UUID{} // Get the workspace from the database - require.Eventually(t, func() bool { + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { ws, err := db.GetWorkspaceByID(ctx, workspace.ID) if err != nil { return false @@ -1689,10 +1718,9 @@ func MustWaitForProvisionersAvailable(t *testing.T, db database.Store, workspace } // Check if any provisioners are active (not stale) - now := time.Now() for _, pd := range provisionerDaemons { if pd.LastSeenAt.Valid { - age := now.Sub(pd.LastSeenAt.Time) + age := ts.Sub(pd.LastSeenAt.Time) if age <= provisionerdserver.StaleInterval { id = pd.ID return true // Found an active provisioner @@ -1700,7 +1728,7 @@ func MustWaitForProvisionersAvailable(t *testing.T, db database.Store, workspace } } return false // No active provisioners found - }, testutil.WaitLong, testutil.IntervalFast) + }, testutil.IntervalFast, "no active provisioners available for workspace, expected at least one (non-stale)") return id } diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 31821bb798de9..555806b62371d 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -2242,13 +2242,14 @@ func TestPrebuildsAutobuild(t *testing.T) { workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, sched.Next(prebuild.LatestBuild.CreatedAt)) + // Wait for provisioner to be available for this specific workspace - coderdtest.MustWaitForProvisionersAvailable(t, db, prebuild) + coderdtest.MustWaitForProvisionersAvailable(t, db, prebuild, sched.Next(prebuild.LatestBuild.CreatedAt)) tickTime := sched.Next(prebuild.LatestBuild.CreatedAt).Add(time.Minute) - p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) require.NoError(t, err) - coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) // Tick at the next scheduled time after the prebuild’s LatestBuild.CreatedAt, // since the next allowed autostart is calculated starting from that point. diff --git a/site/src/components/Filter/UserFilter.tsx b/site/src/components/Filter/UserFilter.tsx index 0663d3d8d97d0..5f0e6804347f2 100644 --- a/site/src/components/Filter/UserFilter.tsx +++ b/site/src/components/Filter/UserFilter.tsx @@ -9,6 +9,8 @@ import { useAuthenticated } from "hooks"; import type { FC } from "react"; import { type UseFilterMenuOptions, useFilterMenu } from "./menu"; +export const DEFAULT_USER_FILTER_WIDTH = 175; + export const useUserFilterMenu = ({ value, onChange, diff --git a/site/src/pages/AuditPage/AuditFilter.tsx b/site/src/pages/AuditPage/AuditFilter.tsx index 973d2d7a8e7ba..49a40b4136ba7 100644 --- a/site/src/pages/AuditPage/AuditFilter.tsx +++ b/site/src/pages/AuditPage/AuditFilter.tsx @@ -8,7 +8,11 @@ import { SelectFilter, type SelectFilterOption, } from "components/Filter/SelectFilter"; -import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter"; +import { + DEFAULT_USER_FILTER_WIDTH, + type UserFilterMenu, + UserMenu, +} from "components/Filter/UserFilter"; import capitalize from "lodash/capitalize"; import { type OrganizationsFilterMenu, @@ -47,8 +51,7 @@ interface AuditFilterProps { } export const AuditFilter: FC = ({ filter, error, menus }) => { - const width = menus.organization ? 175 : undefined; - + const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined; return ( = ({ error, menus, }) => { - const width = menus.organization ? 175 : undefined; - + const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined; return ( ; + filter: UseFilterResult; error?: unknown; + + userMenu?: UserFilterMenu; } export const TemplatesFilter: FC = ({ filter, error, + userMenu, }) => { + const { showOrganizations } = useDashboard(); + const width = showOrganizations ? DEFAULT_USER_FILTER_WIDTH : undefined; const organizationMenu = useFilterMenu({ onChange: (option) => filter.update({ ...filter.values, organization: option?.value }), @@ -50,15 +65,23 @@ export const TemplatesFilter: FC = ({ filter={filter} error={error} options={ - + <> + {userMenu && } + + + } + optionsSkeleton={ + <> + {userMenu && } + + } - optionsSkeleton={} /> ); }; diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index d03d29716b4c9..48132ab175c76 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -1,6 +1,7 @@ import { workspacePermissionsByOrganization } from "api/queries/organizations"; import { templateExamples, templates } from "api/queries/templates"; -import { useFilter } from "components/Filter/Filter"; +import { type UseFilterResult, useFilter } from "components/Filter/Filter"; +import { useUserFilterMenu } from "components/Filter/UserFilter"; import { useAuthenticated } from "hooks"; import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; @@ -15,14 +16,12 @@ const TemplatesPage: FC = () => { const { showOrganizations } = useDashboard(); const [searchParams, setSearchParams] = useSearchParams(); - const filter = useFilter({ - fallbackFilter: "deprecated:false", + const filterState = useTemplatesFilter({ searchParams, onSearchParamsChange: setSearchParams, - onUpdate: () => {}, // reset pagination }); - const templatesQuery = useQuery(templates({ q: filter.query })); + const templatesQuery = useQuery(templates({ q: filterState.filter.query })); const examplesQuery = useQuery({ ...templateExamples(), enabled: permissions.createTemplates, @@ -47,7 +46,7 @@ const TemplatesPage: FC = () => { { }; export default TemplatesPage; + +export type TemplateFilterState = { + filter: UseFilterResult; + menus: { + user?: ReturnType; + }; +}; + +type UseTemplatesFilterOptions = { + searchParams: URLSearchParams; + onSearchParamsChange: (params: URLSearchParams) => void; +}; + +const useTemplatesFilter = ({ + searchParams, + onSearchParamsChange, +}: UseTemplatesFilterOptions): TemplateFilterState => { + const filter = useFilter({ + fallbackFilter: "deprecated:false", + searchParams, + onSearchParamsChange, + }); + + const { permissions } = useAuthenticated(); + const canFilterByUser = permissions.viewAllUsers; + const userMenu = useUserFilterMenu({ + value: filter.values.author, + onChange: (option) => + filter.update({ ...filter.values, author: option?.value }), + enabled: canFilterByUser, + }); + + return { + filter, + menus: { + user: canFilterByUser ? userMenu : undefined, + }, + }; +}; diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index 9d8e55c171ea9..58b0bdb9ff8a8 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -3,24 +3,35 @@ import { MockTemplate, MockTemplateExample, MockTemplateExample2, + MockUserOwner, mockApiError, } from "testHelpers/entities"; import { withDashboardProvider } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { getDefaultFilterProps } from "components/Filter/storyHelpers"; +import { + getDefaultFilterProps, + MockMenu, +} from "components/Filter/storyHelpers"; +import type { TemplateFilterState } from "./TemplatesPage"; import { TemplatesPageView } from "./TemplatesPageView"; +const defaultFilterProps = getDefaultFilterProps({ + query: "deprecated:false", + menus: { + organizations: MockMenu, + }, + values: { + author: MockUserOwner.username, + }, +}); + const meta: Meta = { title: "pages/TemplatesPage", decorators: [withDashboardProvider], parameters: { chromatic: chromaticWithTablet }, component: TemplatesPageView, args: { - ...getDefaultFilterProps({ - query: "deprecated:false", - menus: {}, - values: {}, - }), + filterState: defaultFilterProps, }, }; @@ -104,12 +115,32 @@ export const WithFilteredAllTemplates: Story = { args: { ...WithTemplates.args, templates: [], - ...getDefaultFilterProps({ - query: "deprecated:false searchnotfound", - menus: {}, - values: {}, - used: true, - }), + filterState: { + filter: { + ...defaultFilterProps.filter, + query: "deprecated:false searchnotfound", + values: {}, + used: true, + }, + menus: defaultFilterProps.menus, + }, + }, +}; + +export const WithUserDropdown: Story = { + args: { + ...WithTemplates.args, + filterState: { + ...defaultFilterProps, + menus: { + user: MockMenu, + }, + filter: { + ...defaultFilterProps.filter, + query: "author:me", + values: { author: "me" }, + }, + }, }, }; diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index a37cb31232816..e36b278949497 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -9,7 +9,6 @@ import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; import { DeprecatedBadge } from "components/Badges/Badges"; import { Button } from "components/Button/Button"; -import type { useFilter } from "components/Filter/Filter"; import { HelpTooltip, HelpTooltipContent, @@ -52,6 +51,7 @@ import { } from "utils/templates"; import { EmptyTemplates } from "./EmptyTemplates"; import { TemplatesFilter } from "./TemplatesFilter"; +import type { TemplateFilterState } from "./TemplatesPage"; const Language = { developerCount: (activeCount: number): string => { @@ -184,7 +184,7 @@ const TemplateRow: FC = ({ interface TemplatesPageViewProps { error?: unknown; - filter: ReturnType; + filterState: TemplateFilterState; showOrganizations: boolean; canCreateTemplates: boolean; examples: TemplateExample[] | undefined; @@ -194,7 +194,7 @@ interface TemplatesPageViewProps { export const TemplatesPageView: FC = ({ error, - filter, + filterState, showOrganizations, canCreateTemplates, examples, @@ -229,7 +229,11 @@ export const TemplatesPageView: FC = ({ - + {/* Validation errors are shown on the filter, other errors are an alert box. */} {hasError(error) && !isApiValidationError(error) && ( @@ -256,7 +260,7 @@ export const TemplatesPageView: FC = ({ ) : ( templates?.map((template) => ( diff --git a/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx b/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx index caebfd04526d4..8f45143ffa068 100644 --- a/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx +++ b/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx @@ -3,7 +3,11 @@ import { MenuSkeleton, type UseFilterResult, } from "components/Filter/Filter"; -import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter"; +import { + DEFAULT_USER_FILTER_WIDTH, + type UserFilterMenu, + UserMenu, +} from "components/Filter/UserFilter"; import { useDashboard } from "modules/dashboard/useDashboard"; import { type OrganizationsFilterMenu, @@ -96,7 +100,7 @@ export const WorkspacesFilter: FC = ({ organizationsMenu, }) => { const { entitlements, showOrganizations } = useDashboard(); - const width = showOrganizations ? 175 : undefined; + const width = showOrganizations ? DEFAULT_USER_FILTER_WIDTH : undefined; const presets = entitlements.features.advanced_template_scheduling.enabled ? PRESETS_WITH_DORMANT : PRESET_FILTERS;