From 770e4734aecf1d12b422f80a7c252d54e42c3963 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 14 Oct 2022 04:01:00 +0000 Subject: [PATCH 01/42] Start - still needs api call changes --- .../pages/WorkspacesPage/WorkspacesPage.tsx | 26 +++++++++++++- .../WorkspacesPage/WorkspacesPageView.tsx | 34 ++++++++++++++++++- .../workspaces/workspacesXService.ts | 33 +++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 41e122cb617ad..fa64cbb804237 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -10,13 +10,25 @@ import { WorkspacesPageView } from "./WorkspacesPageView" const WorkspacesPage: FC = () => { const [searchParams, setSearchParams] = useSearchParams() const filter = searchParams.get("filter") ?? workspaceFilterQuery.me + const currentPage = searchParams.get("page") + ? Number(searchParams.get("page")) + : 1 const [workspacesState, send] = useMachine(workspacesMachine, { context: { + page: currentPage, + limit: 25, filter, }, + actions: { + onPageChange: ({ page }) => { + navigate({ + search: `?page=${page}`, + }) + }, + }, }) - const { workspaceRefs } = workspacesState.context + const { workspaceRefs, count, page, limit } = workspacesState.context return ( <> @@ -28,6 +40,18 @@ const WorkspacesPage: FC = () => { filter={workspacesState.context.filter} isLoading={!workspaceRefs} workspaceRefs={workspaceRefs} + count={count} + page={page} + limit={limit} + onNext={() => { + send("NEXT") + }} + onPrevious={() => { + send("PREVIOUS") + }} + onGoToPage={(page) => { + send("GO_TO_PAGE", { page }) + }} onFilter={(query) => { setSearchParams({ filter: query }) send({ diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 097f24d732406..42a28adb7f123 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -1,4 +1,6 @@ import Link from "@material-ui/core/Link" +import { Maybe } from "components/Conditionals/Maybe" +import { PaginationWidget } from "components/PaginationWidget/PaginationWidget" import { FC } from "react" import { Link as RouterLink } from "react-router-dom" import { Margins } from "../../components/Margins/Margins" @@ -26,13 +28,30 @@ export const Language = { export interface WorkspacesPageViewProps { isLoading?: boolean workspaceRefs?: WorkspaceItemMachineRef[] + count?: number + page: number + limit: number filter?: string onFilter: (query: string) => void + onNext: () => void + onPrevious: () => void + onGoToPage: (page: number) => void } export const WorkspacesPageView: FC< React.PropsWithChildren -> = ({ isLoading, workspaceRefs, filter, onFilter }) => { +> = ({ + isLoading, + workspaceRefs, + count, + page, + limit, + filter, + onFilter, + onNext, + onPrevious, + onGoToPage, +}) => { const presetFilters = [ { query: workspaceFilterQuery.me, name: Language.yourWorkspacesButton }, { query: workspaceFilterQuery.all, name: Language.allWorkspacesButton }, @@ -72,6 +91,19 @@ export const WorkspacesPageView: FC< workspaceRefs={workspaceRefs} filter={filter} /> + + limit}> + + ) } diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index 7b0844dddc152..a0b318757089f 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -206,11 +206,17 @@ interface WorkspacesContext { workspaceRefs?: WorkspaceItemMachineRef[] filter: string getWorkspacesError?: Error | unknown + page: number + count?: number + limit: number } type WorkspacesEvent = | { type: "GET_WORKSPACES"; query?: string } | { type: "UPDATE_VERSION"; workspaceId: string } + | { type: "NEXT" } + | { type: "PREVIOUS" } + | { type: "GO_TO_PAGE"; page: number } export const workspacesMachine = createMachine( { @@ -235,12 +241,24 @@ export const workspacesMachine = createMachine( on: { GET_WORKSPACES: { actions: "assignFilter", - target: ".gettingWorkspaces", + target: "gettingWorkspaces", internal: false, }, UPDATE_VERSION: { actions: "triggerUpdateVersion", }, + NEXT: { + actions: ["assignNextPage", "onPageChange"], + target: "gettingWorkspaces", + }, + PREVIOUS: { + actions: ["assignPreviousPage", "onPageChange"], + target: "gettingWorkspaces", + }, + GO_TO_PAGE: { + actions: ["assignPage", "onPageChange"], + target: "gettingWorkspaces", + }, }, initial: "gettingWorkspaces", states: { @@ -320,6 +338,7 @@ export const workspacesMachine = createMachine( }, assignUpdatedWorkspaceRefs: assign({ workspaceRefs: (_, event) => { + const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) => spawn( workspaceItemMachine.withContext({ data: workspace }), @@ -329,6 +348,18 @@ export const workspacesMachine = createMachine( return event.data.refsToKeep.concat(newWorkspaceRefs) }, }), + assignNextPage: assign({ + page: ({ page }) => page + 1, + }), + assignPreviousPage: assign({ + page: ({ page }) => page - 1, + }), + assignPage: assign({ + page: (_, { page }) => page, + }), + assignCount: assign({ + count: (_, { count }) => count + }) }, services: { getWorkspaces: (context) => From 13256ce57058196a9564364c0f16e3c28e3971c8 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 14 Oct 2022 18:22:04 +0000 Subject: [PATCH 02/42] Some xservice changes --- .../xServices/workspaces/workspacesXService.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index a0b318757089f..b8330fa9e02d7 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -228,6 +228,9 @@ export const workspacesMachine = createMachine( getWorkspaces: { data: TypesGen.Workspace[] } + getWorkspacesCount: { + data: { count: number } + } updateWorkspaceRefs: { data: { refsToKeep: WorkspaceItemMachineRef[] @@ -349,16 +352,16 @@ export const workspacesMachine = createMachine( }, }), assignNextPage: assign({ - page: ({ page }) => page + 1, + page: (context) => context.page + 1, }), assignPreviousPage: assign({ - page: ({ page }) => page - 1, + page: (context) => context.page - 1, }), assignPage: assign({ - page: (_, { page }) => page, + page: (_, event) => event.page, }), assignCount: assign({ - count: (_, { count }) => count + count: (_, event) => event.data.count }) }, services: { @@ -391,6 +394,10 @@ export const workspacesMachine = createMachine( newWorkspaces, }) }, + getWorkspacesCount: () => { + //API.getWorkspacesCount() + return Promise.resolve({ count: 10 }) + } }, }, ) From 76071b9ded42f49426af7156bbaab1faa2b32677 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 14 Oct 2022 19:11:40 +0000 Subject: [PATCH 03/42] Finish adding count to xservice --- .../workspaces/workspacesXService.ts | 194 +++++++++++------- 1 file changed, 120 insertions(+), 74 deletions(-) diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index b8330fa9e02d7..de3a46e039e99 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -218,97 +218,143 @@ type WorkspacesEvent = | { type: "PREVIOUS" } | { type: "GO_TO_PAGE"; page: number } -export const workspacesMachine = createMachine( +export const workspacesMachine = +/** @xstate-layout N4IgpgJg5mDOIC5QHcD2AnA1rADgQwGM4BlAFz1LADpk8BLUgFVQCUwAzdOACwHUNs+IrADEAD1jlKVPO0roAFAFYADGoCUItFlyESU6rQbM2nHvx1C4AbRUBdRKBypYDOqgB2jkGMQBGAGYANgCqFQAmAE4-cIAOOPDwoIB2ZIAaEABPRHCVKljAv2S-EMiClSUAFliAXxqM7UE9WDIKanYwUgJuOg8oKgJUAFcPUioYUlJeqABhYdGRCE9qXoA3VExqCYsm4TmR0lsHJBBnVynPb18ESsq8uOLk2KCVSqVY4qUM7IQAyqoApElOFKskgpEUn5avUQI1dMJWtIOl0en0BvMxhMpn19gswOh0BgqDgADYUdgYAC2406O3hcFxh3s3jObkuJ2ufyUVEiAXi4QCySUAWBARF3xyaiowuqZShQSCiUidQaAnpLQMVGR3WmNDVVlgVCGOAgFGmdKsplESw8Kw8602RpNbQteitRxZLjZXg5iFitwByTKARUQVulQCiQlCBeeX9sWifihhXBKth+uaiPanR1aLhBppk3NGeEi2WVDWGy2tJLNmZJ1ZFx9oGub25f0jyQqSiUQvi0aUiqoiXCfnecT8kRUwTT+czmu1qP6c+EhexUFdpZtdod1dIm5sfmOTi9TauiFukWlCb5097-tD0cqQ57dw+8cCz9ntY1bS1OaXPVLGaNdi2A0t8UJdBiTJUgKXQalth-D0G1Pdxmx8fximHF5O1uVJKgFAJoxCZIqChPlIhBUMngib9wP0P9F2mMtbSoSQ-xXRikQA6YUJPc50PPBBAhSYcQWBJJcmBfsshyIpxNHLsCiUIFIyCejdm4sARAAcQAUUYAB9XgAHkWAAaWIAAFABBGZ9OIfjTjQ9kW0QABaQiwmKadkhDQjgmSSpow8gIx3Ivkg17KIwSUPxNPVLMRAAVWsgARWzGH0oyADV9JYYgAElTIAOWcxshN9BB4jIsdhReSpIgjJrwlCkU-CoYKlQqQVYniRKDWS0r9IADUYCrXIw64Sm5XsFSSHtR1BQJ2r8f5Xg+KIww+YogiUQb5zaERrJYfTcpKlKnPrATvWEwV-nBUdR0HZqRTDNaNuqZJtu+vaDphLjf0oPTTKMxgwbsgzJsEtzMIQd4gi6ypRNBO4BWeT6wm+37dtmuoYQ8VAIDgbwgazGh6CYVgOC4WA+B-T1Yem-wxUe3sij6uIOujUSeSCKF-MFXlAmBQ6EQXXi0UGA5QJxDEmbu6q-mHVT+oTEIYkIkK5N+K9ARUeIIlHNQRUqcXtP-FFdRl0YqG3RWz2qkIkdeP5XmKEEFujUckZ+0dnj5DGewCC3geza3pYV1DmeEjzAmRlQkz8acgg+ebQqqKhGqFCNIkiIUkmhVUGPDq3c2XH8nVNdcDytR2qvcmrIx5TmhUBJIk6+XXbliLrB369SDZTgGS60svmLzKusTA8eG7hzl-l7YoqKIxUShKJ83n7tOnnC7bwkHMOKcnyvS-t5Z55ZhBin+ajfIxsUIWjfy5pSXlFVDAfQ8Bn8T6ls+c8Y5KybvHUIhE4gqFSAqPqfIdY-HejvWIlFqh8gesfSWkcoBXzjgLROydU7pzBKFdaoQc6XjBOGEUGC2g4OqvHFQV5gpJyTIQoUxDdZeVeJFD4zUIypGKD-OoQA */ +createMachine( { - tsTypes: {} as import("./workspacesXService.typegen").Typegen1, - schema: { - context: {} as WorkspacesContext, - events: {} as WorkspacesEvent, - services: {} as { - getWorkspaces: { - data: TypesGen.Workspace[] - } + tsTypes: {} as import("./workspacesXService.typegen").Typegen1, + schema: { + context: {} as WorkspacesContext, + events: {} as WorkspacesEvent, + services: {} as { + getWorkspaces: { + data: TypesGen.Workspace[] + } getWorkspacesCount: { data: { count: number } } - updateWorkspaceRefs: { - data: { - refsToKeep: WorkspaceItemMachineRef[] - newWorkspaces: TypesGen.Workspace[] - } + updateWorkspaceRefs: { + data: { + refsToKeep: WorkspaceItemMachineRef[] + newWorkspaces: TypesGen.Workspace[] } - }, + } }, - predictableActionArguments: true, - id: "workspacesState", - on: { - GET_WORKSPACES: { - actions: "assignFilter", - target: "gettingWorkspaces", - internal: false, - }, - UPDATE_VERSION: { - actions: "triggerUpdateVersion", - }, - NEXT: { - actions: ["assignNextPage", "onPageChange"], - target: "gettingWorkspaces", - }, - PREVIOUS: { - actions: ["assignPreviousPage", "onPageChange"], - target: "gettingWorkspaces", - }, - GO_TO_PAGE: { - actions: ["assignPage", "onPageChange"], - target: "gettingWorkspaces", + }, + predictableActionArguments: true, + id: "workspacesState", + on: { + GET_WORKSPACES: { + target: ".fetching", + actions: "assignFilter", + }, + UPDATE_VERSION: { + actions: "triggerUpdateVersion", + }, + NEXT: { + target: ".fetching", + actions: ["assignNextPage", "onPageChange"], + }, + PREVIOUS: { + target: ".fetching", + actions: ["assignPreviousPage", "onPageChange"], + }, + GO_TO_PAGE: { + target: ".fetching", + actions: ["assignPage", "onPageChange"], + }, + }, + initial: "fetching", + states: { + waitToRefreshWorkspaces: { + after: { + "5000": { + target: "#workspacesState.fetching", + actions: [], + internal: false, + }, }, }, - initial: "gettingWorkspaces", - states: { - gettingWorkspaces: { - entry: "clearGetWorkspacesError", - invoke: { - src: "getWorkspaces", - id: "getWorkspaces", - onDone: [ - { - actions: "assignWorkspaceRefs", - cond: "isEmpty", - target: "waitToRefreshWorkspaces", - }, - { - target: "updatingWorkspaceRefs", + fetching: { + type: "parallel", + states: { + count: { + initial: "gettingCount", + states: { + gettingCount: { + entry: "clearGetCountError", + invoke: { + src: "getWorkspacesCount", + id: "getWorkspacesCount", + onDone: [ + { + target: "done", + actions: "assignCount", + }, + ], + onError: [ + { + target: "done", + actions: "assignGetCountError", + }, + ], + }, }, - ], - onError: [ - { - actions: "assignGetWorkspacesError", - target: "waitToRefreshWorkspaces", + done: { + type: "final", }, - ], + }, }, - }, - updatingWorkspaceRefs: { - invoke: { - src: "updateWorkspaceRefs", - id: "updateWorkspaceRefs", - onDone: [ - { - actions: "assignUpdatedWorkspaceRefs", - target: "waitToRefreshWorkspaces", + workspaces: { + initial: "gettingWorkspaces", + states: { + updatingWorkspaceRefs: { + invoke: { + src: "updateWorkspaceRefs", + id: "updateWorkspaceRefs", + onDone: [ + { + target: "done", + actions: "assignUpdatedWorkspaceRefs" + }, + ], + }, + }, + gettingWorkspaces: { + entry: "clearGetWorkspacesError", + invoke: { + src: "getWorkspaces", + id: "getWorkspaces", + onDone: [ + { + target: "done", + cond: "isEmpty", + actions: "assignWorkspaceRefs", + }, + { + target: "updatingWorkspaceRefs", + }, + ], + onError: [ + { + target: "done", + actions: "assignGetWorkspacesError", + }, + ], + }, + }, + done: { + type: "final", }, - ], - }, - }, - waitToRefreshWorkspaces: { - after: { - "5000": { - target: "gettingWorkspaces", }, }, }, + onDone: { + target: "waitToRefreshWorkspaces", + }, }, }, +}, { guards: { isEmpty: (context) => !context.workspaceRefs, From bdb061404ad536b7a035d92e7d1c6f8b6deaf141 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 14 Oct 2022 19:21:45 +0000 Subject: [PATCH 04/42] Mock out api call on frontend --- site/src/api/api.ts | 17 +++++++++++++++++ .../xServices/workspaces/workspacesXService.ts | 8 +++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2e60a88b8469c..54a31b7eaa606 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -286,6 +286,23 @@ export const getWorkspaces = async ( return response.data } +// TODO change types +export const getWorkspacesCount = async ( + options: TypesGen.AuditLogCountRequest = {} +): Promise => { + const searchParams = new URLSearchParams() + if (options.q) { + searchParams.set("q", options.q) + } + // TODO + // const response = await axios.get( + // `/api/v2/workspaces/count?${searchParams.toString()}`, + // ) + // return response.data + + return Promise.resolve({ count: 10 }) +} + export const getWorkspaceByOwnerAndName = async ( username = "me", workspaceName: string, diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index de3a46e039e99..78b4232350cf6 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -387,7 +387,6 @@ createMachine( }, assignUpdatedWorkspaceRefs: assign({ workspaceRefs: (_, event) => { - const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) => spawn( workspaceItemMachine.withContext({ data: workspace }), @@ -440,10 +439,9 @@ createMachine( newWorkspaces, }) }, - getWorkspacesCount: () => { - //API.getWorkspacesCount() - return Promise.resolve({ count: 10 }) - } + getWorkspacesCount: (context) => ( + API.getWorkspacesCount({ q: context.filter }) + ) }, }, ) From 86caa8026308d0505d23371f47f0d90b6f3018af Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 14 Oct 2022 19:57:29 +0000 Subject: [PATCH 05/42] Handle errors --- site/src/api/api.ts | 2 +- .../components/AlertBanner/AlertBanner.tsx | 12 +- .../pages/WorkspacesPage/WorkspacesPage.tsx | 16 +- .../WorkspacesPage/WorkspacesPageView.tsx | 20 + .../workspaces/workspacesXService.ts | 408 +++++++++--------- 5 files changed, 247 insertions(+), 211 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 54a31b7eaa606..cf7e8f30bfa7f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -288,7 +288,7 @@ export const getWorkspaces = async ( // TODO change types export const getWorkspacesCount = async ( - options: TypesGen.AuditLogCountRequest = {} + options: TypesGen.AuditLogCountRequest = {}, ): Promise => { const searchParams = new URLSearchParams() if (options.q) { diff --git a/site/src/components/AlertBanner/AlertBanner.tsx b/site/src/components/AlertBanner/AlertBanner.tsx index 9c857811908a0..1f79f2e8d1a76 100644 --- a/site/src/components/AlertBanner/AlertBanner.tsx +++ b/site/src/components/AlertBanner/AlertBanner.tsx @@ -11,12 +11,12 @@ import { severityConstants } from "./severityConstants" import { AlertBannerCtas } from "./AlertBannerCtas" /** - * severity: the level of alert severity (see ./severityTypes.ts) - * text: default text to be displayed to the user; useful for warnings or as a fallback error message - * error: should be passed in if the severity is 'Error'; warnings can use 'text' instead - * actions: an array of CTAs passed in by the consumer - * dismissible: determines whether or not the banner should have a `Dismiss` CTA - * retry: a handler to retry the action that spawned the error + * @param severity: the level of alert severity (see ./severityTypes.ts) + * @param text: default text to be displayed to the user; useful for warnings or as a fallback error message + * @param error: should be passed in if the severity is 'Error'; warnings can use 'text' instead + * @param actions: an array of CTAs passed in by the consumer + * @param dismissible: determines whether or not the banner should have a `Dismiss` CTA + * @param retry: a handler to retry the action that spawned the error */ export const AlertBanner: FC = ({ severity, diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index fa64cbb804237..d9f53b8d49120 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,13 +1,14 @@ import { useMachine } from "@xstate/react" import { FC } from "react" import { Helmet } from "react-helmet-async" -import { useSearchParams } from "react-router-dom" +import { useNavigate, useSearchParams } from "react-router-dom" import { workspaceFilterQuery } from "util/filters" import { pageTitle } from "util/page" import { workspacesMachine } from "xServices/workspaces/workspacesXService" import { WorkspacesPageView } from "./WorkspacesPageView" const WorkspacesPage: FC = () => { + const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const filter = searchParams.get("filter") ?? workspaceFilterQuery.me const currentPage = searchParams.get("page") @@ -16,7 +17,7 @@ const WorkspacesPage: FC = () => { const [workspacesState, send] = useMachine(workspacesMachine, { context: { page: currentPage, - limit: 25, + limit: 2, //TODO filter, }, actions: { @@ -28,7 +29,14 @@ const WorkspacesPage: FC = () => { }, }) - const { workspaceRefs, count, page, limit } = workspacesState.context + const { + workspaceRefs, + count, + page, + limit, + getWorkspacesError, + getCountError, + } = workspacesState.context return ( <> @@ -41,6 +49,8 @@ const WorkspacesPage: FC = () => { isLoading={!workspaceRefs} workspaceRefs={workspaceRefs} count={count} + getWorkspacesError={getWorkspacesError} + getCountError={getCountError} page={page} limit={limit} onNext={() => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 42a28adb7f123..a016e1fd20590 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -1,4 +1,5 @@ import Link from "@material-ui/core/Link" +import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" import { PaginationWidget } from "components/PaginationWidget/PaginationWidget" import { FC } from "react" @@ -29,6 +30,8 @@ export interface WorkspacesPageViewProps { isLoading?: boolean workspaceRefs?: WorkspaceItemMachineRef[] count?: number + getWorkspacesError: Error | unknown + getCountError: Error | unknown page: number limit: number filter?: string @@ -44,6 +47,8 @@ export const WorkspacesPageView: FC< isLoading, workspaceRefs, count, + getWorkspacesError, + getCountError, page, limit, filter, @@ -80,6 +85,21 @@ export const WorkspacesPageView: FC< + + 0 + ? "warning" + : "error" + } + /> + + + + + + !context.workspaceRefs, - }, - actions: { - assignWorkspaceRefs: assign({ - workspaceRefs: (_, event) => - event.data.map((data) => { - return spawn(workspaceItemMachine.withContext({ data }), data.id) - }), - }), - assignFilter: assign({ - filter: (context, event) => event.query ?? context.filter, - }), - assignGetWorkspacesError: assign({ - getWorkspacesError: (_, event) => event.data, - }), - clearGetWorkspacesError: (context) => - assign({ ...context, getWorkspacesError: undefined }), - triggerUpdateVersion: (context, event) => { - const workspaceRef = context.workspaceRefs?.find( - (ref) => ref.id === event.workspaceId, - ) - - if (!workspaceRef) { - throw new Error(`No workspace ref found for ${event.workspaceId}.`) - } - - workspaceRef.send("UPDATE_VERSION") + { + guards: { + isEmpty: (context) => !context.workspaceRefs, }, - assignUpdatedWorkspaceRefs: assign({ - workspaceRefs: (_, event) => { - const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) => - spawn( - workspaceItemMachine.withContext({ data: workspace }), - workspace.id, - ), - ) - return event.data.refsToKeep.concat(newWorkspaceRefs) - }, - }), - assignNextPage: assign({ - page: (context) => context.page + 1, - }), - assignPreviousPage: assign({ - page: (context) => context.page - 1, - }), - assignPage: assign({ - page: (_, event) => event.page, - }), - assignCount: assign({ - count: (_, event) => event.data.count - }) - }, - services: { - getWorkspaces: (context) => - API.getWorkspaces(queryToFilter(context.filter)), - updateWorkspaceRefs: (context, event) => { - const refsToKeep: WorkspaceItemMachineRef[] = [] - context.workspaceRefs?.forEach((ref) => { - const matchingWorkspace = event.data.find( - (workspace) => ref.id === workspace.id, + actions: { + assignWorkspaceRefs: assign({ + workspaceRefs: (_, event) => + event.data.map((data) => { + return spawn(workspaceItemMachine.withContext({ data }), data.id) + }), + }), + assignFilter: assign({ + filter: (context, event) => event.query ?? context.filter, + }), + assignGetWorkspacesError: assign({ + getWorkspacesError: (_, event) => event.data, + }), + clearGetWorkspacesError: (context) => + assign({ ...context, getWorkspacesError: undefined }), + triggerUpdateVersion: (context, event) => { + const workspaceRef = context.workspaceRefs?.find( + (ref) => ref.id === event.workspaceId, ) - if (matchingWorkspace) { - // if a workspace machine reference describes a workspace that has not been deleted, - // update its data and mark it as a refToKeep - ref.send({ type: "UPDATE_DATA", data: matchingWorkspace }) - refsToKeep.push(ref) - } else { - // if it describes a workspace that has been deleted, stop the machine - ref.stop && ref.stop() + + if (!workspaceRef) { + throw new Error(`No workspace ref found for ${event.workspaceId}.`) } - }) - const newWorkspaces = event.data.filter( - (workspace) => - !context.workspaceRefs?.find((ref) => ref.id === workspace.id), - ) + workspaceRef.send("UPDATE_VERSION") + }, + assignUpdatedWorkspaceRefs: assign({ + workspaceRefs: (_, event) => { + const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) => + spawn( + workspaceItemMachine.withContext({ data: workspace }), + workspace.id, + ), + ) + return event.data.refsToKeep.concat(newWorkspaceRefs) + }, + }), + assignNextPage: assign({ + page: (context) => context.page + 1, + }), + assignPreviousPage: assign({ + page: (context) => context.page - 1, + }), + assignPage: assign({ + page: (_, event) => event.page, + }), + assignCount: assign({ + count: (_, event) => event.data.count, + }), + assignGetCountError: assign({ + getCountError: (_, event) => event.data, + }), + clearGetCountError: assign({ + getCountError: (_) => undefined, + }), + }, + services: { + getWorkspaces: (context) => + API.getWorkspaces(queryToFilter(context.filter)), + updateWorkspaceRefs: (context, event) => { + const refsToKeep: WorkspaceItemMachineRef[] = [] + context.workspaceRefs?.forEach((ref) => { + const matchingWorkspace = event.data.find( + (workspace) => ref.id === workspace.id, + ) + if (matchingWorkspace) { + // if a workspace machine reference describes a workspace that has not been deleted, + // update its data and mark it as a refToKeep + ref.send({ type: "UPDATE_DATA", data: matchingWorkspace }) + refsToKeep.push(ref) + } else { + // if it describes a workspace that has been deleted, stop the machine + ref.stop && ref.stop() + } + }) + + const newWorkspaces = event.data.filter( + (workspace) => + !context.workspaceRefs?.find((ref) => ref.id === workspace.id), + ) - return Promise.resolve({ - refsToKeep, - newWorkspaces, - }) + return Promise.resolve({ + refsToKeep, + newWorkspaces, + }) + }, + getWorkspacesCount: (context) => + API.getWorkspacesCount({ q: context.filter }), }, - getWorkspacesCount: (context) => ( - API.getWorkspacesCount({ q: context.filter }) - ) }, - }, -) + ) From 931715521a2f2bc288d64883553ad71976e9b99f Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 14 Oct 2022 20:08:54 +0000 Subject: [PATCH 06/42] Doctor getWorkspaces --- site/src/api/api.ts | 16 +++++++++++++--- .../xServices/workspaces/workspacesXService.ts | 6 +++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index cf7e8f30bfa7f..80818a97c8f7b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -279,10 +279,20 @@ export const getURLWithSearchParams = ( } export const getWorkspaces = async ( - filter?: TypesGen.WorkspaceFilter, + options: TypesGen.AuditLogsRequest, ): Promise => { - const url = getURLWithSearchParams("/api/v2/workspaces", filter) - const response = await axios.get(url) + const searchParams = new URLSearchParams() + if (options.limit) { + searchParams.set("limit", options.limit.toString()) + } + if (options.offset) { + searchParams.set("offset", options.offset.toString()) + } + if (options.q) { + searchParams.set("q", options.q) + } + + const response = await axios.get(`/api/v2/workspaces?${searchParams.toString()}`) return response.data } diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index ca415ca8b07f9..822ae8b0a9995 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -418,7 +418,11 @@ export const workspacesMachine = }, services: { getWorkspaces: (context) => - API.getWorkspaces(queryToFilter(context.filter)), + API.getWorkspaces({ + ...queryToFilter(context.filter), + offset: (context.page - 1) * context.limit, + limit: context.limit, + }), updateWorkspaceRefs: (context, event) => { const refsToKeep: WorkspaceItemMachineRef[] = [] context.workspaceRefs?.forEach((ref) => { From 2a8c1b33cfefcd883b449a0798659e285abe2e41 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 14 Oct 2022 21:26:17 +0000 Subject: [PATCH 07/42] Add types, start writing count function --- coderd/workspaces.go | 42 ++++++++++++++++++++++++++++++++++ codersdk/workspaces.go | 12 ++++++++++ site/src/api/api.ts | 7 +++--- site/src/api/typesGenerated.ts | 15 ++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c96967b1266ed..869e71046e43e 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -157,6 +157,48 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, wss) } +func (api *API) workspaceCount(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + queryStr := r.URL.Query().Get("q") + filter, errs := workspaceSearchQuery(queryStr) + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid audit search query.", + Validations: errs, + }) + return + } + + if filter.OwnerUsername == "me" { + filter.OwnerID = apiKey.UserID + filter.OwnerUsername = "" + } + + sqlFilter, err := api.HTTPAuth.AuthorizeSQLFilter(r, rbac.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error preparing sql filter.", + Detail: err.Error(), + }) + return + } + + count, err := api.Database.GetWorkspaceCount(ctx, filter, sqlFilter) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace count.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceCountResponse{ + Count: count, + }) +} + func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() owner := httpmw.UserParam(r) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a019504ad9d09..17b26257c56b4 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -31,6 +31,18 @@ type Workspace struct { LastUsedAt time.Time `json:"last_used_at"` } +type WorkspacesRequest struct { + SearchQuery string `json:"q,omitempty"` + Pagination +} + +type WorkspaceCountRequest struct { + SearchQuery string `json:"q,omitempty"` +} +type WorkspaceCountResponse struct { + Count int64 `json:"count"` +} + // CreateWorkspaceBuildRequest provides options to update the latest workspace build. type CreateWorkspaceBuildRequest struct { TemplateVersionID uuid.UUID `json:"template_version_id,omitempty"` diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 80818a97c8f7b..7a490cce8e7cc 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -279,7 +279,7 @@ export const getURLWithSearchParams = ( } export const getWorkspaces = async ( - options: TypesGen.AuditLogsRequest, + options: TypesGen.WorkspacesRequest, ): Promise => { const searchParams = new URLSearchParams() if (options.limit) { @@ -296,10 +296,9 @@ export const getWorkspaces = async ( return response.data } -// TODO change types export const getWorkspacesCount = async ( - options: TypesGen.AuditLogCountRequest = {}, -): Promise => { + options: TypesGen.WorkspaceCountRequest, +): Promise => { const searchParams = new URLSearchParams() if (options.q) { searchParams.set("q", options.q) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 92088b978f77a..44c8df3bd1a87 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -814,6 +814,16 @@ export interface WorkspaceBuildsRequest extends Pagination { readonly Since: string } +// From codersdk/workspaces.go +export interface WorkspaceCountRequest { + readonly q?: string +} + +// From codersdk/workspaces.go +export interface WorkspaceCountResponse { + readonly count: number +} + // From codersdk/workspaces.go export interface WorkspaceFilter { readonly q?: string @@ -851,6 +861,11 @@ export interface WorkspaceResourceMetadata { readonly sensitive: boolean } +// From codersdk/workspaces.go +export interface WorkspacesRequest extends Pagination { + readonly q?: string +} + // From codersdk/audit.go export type AuditAction = "create" | "delete" | "write" From f5017868350ebbff0d8968c124fa616a8a995cee Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Fri, 14 Oct 2022 21:30:27 +0000 Subject: [PATCH 08/42] Hook up route --- coderd/coderd.go | 1 + site/src/api/api.ts | 11 ++++------- site/src/pages/WorkspacesPage/WorkspacesPage.tsx | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 29439a2001a99..171945f2046e7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -469,6 +469,7 @@ func New(options *Options) *API { apiKeyMiddleware, ) r.Get("/", api.workspaces) + r.Get("/count", api.workspaceCount) r.Route("/{workspace}", func(r chi.Router) { r.Use( httpmw.ExtractWorkspaceParam(options.Database), diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7a490cce8e7cc..cd0de7980e977 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -303,13 +303,10 @@ export const getWorkspacesCount = async ( if (options.q) { searchParams.set("q", options.q) } - // TODO - // const response = await axios.get( - // `/api/v2/workspaces/count?${searchParams.toString()}`, - // ) - // return response.data - - return Promise.resolve({ count: 10 }) + const response = await axios.get( + `/api/v2/workspaces/count?${searchParams.toString()}`, + ) + return response.data } export const getWorkspaceByOwnerAndName = async ( diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index d9f53b8d49120..b12ae2c7bd6fb 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -17,7 +17,7 @@ const WorkspacesPage: FC = () => { const [workspacesState, send] = useMachine(workspacesMachine, { context: { page: currentPage, - limit: 2, //TODO + limit: 25, filter, }, actions: { From fbcfa36dae363d206a38dac38aece8cf7e3fd76f Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 17 Oct 2022 18:58:20 +0000 Subject: [PATCH 09/42] Use empty page struct --- coderd/workspaces.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 869e71046e43e..614a7ba905b35 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -162,7 +162,7 @@ func (api *API) workspaceCount(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) queryStr := r.URL.Query().Get("q") - filter, errs := workspaceSearchQuery(queryStr) + filter, errs := workspaceSearchQuery(queryStr, codersdk.Pagination{}) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid audit search query.", @@ -185,7 +185,7 @@ func (api *API) workspaceCount(rw http.ResponseWriter, r *http.Request) { return } - count, err := api.Database.GetWorkspaceCount(ctx, filter, sqlFilter) + count, err := api.Database.GetAuthorizedWorkspaceCount(ctx, filter, sqlFilter) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace count.", From 939dcdc0ac23204580b3a1e71754798e2d5a26d1 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 17 Oct 2022 19:42:32 +0000 Subject: [PATCH 10/42] Write interface and database fake --- coderd/database/databasefake/databasefake.go | 150 +++++++++++++++++++ coderd/database/modelqueries.go | 51 +++++++ 2 files changed, 201 insertions(+) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index bdb4c8e0f0e40..a25f35e604251 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -711,6 +711,156 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. return workspaces, nil } +func (q *fakeQuerier) GetWorkspaceCount(ctx context.Context, arg database.GetWorkspaceCountParams) (int64, error) { + count, err := q.GetAuthorizedWorkspaceCount(ctx, arg, nil) + return count, err +} + +//nolint:gocyclo +func (q *fakeQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg database.GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspaces { + if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID { + continue + } + + if arg.OwnerUsername != "" { + owner, err := q.GetUserByID(ctx, workspace.OwnerID) + if err == nil && !strings.EqualFold(arg.OwnerUsername, owner.Username) { + continue + } + } + + if arg.TemplateName != "" { + template, err := q.GetTemplateByID(ctx, workspace.TemplateID) + if err == nil && !strings.EqualFold(arg.TemplateName, template.Name) { + continue + } + } + + if !arg.Deleted && workspace.Deleted { + continue + } + + if arg.Name != "" && !strings.Contains(strings.ToLower(workspace.Name), strings.ToLower(arg.Name)) { + continue + } + + if arg.Status != "" { + build, err := q.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + return 0, xerrors.Errorf("get latest build: %w", err) + } + + job, err := q.GetProvisionerJobByID(ctx, build.JobID) + if err != nil { + return 0, xerrors.Errorf("get provisioner job: %w", err) + } + + switch arg.Status { + case "pending": + if !job.StartedAt.Valid { + continue + } + + case "starting": + if !job.StartedAt.Valid && + !job.CanceledAt.Valid && + job.CompletedAt.Valid && + time.Since(job.UpdatedAt) > 30*time.Second || + build.Transition != database.WorkspaceTransitionStart { + continue + } + + case "running": + if !job.CompletedAt.Valid && + job.CanceledAt.Valid && + job.Error.Valid || + build.Transition != database.WorkspaceTransitionStart { + continue + } + + case "stopping": + if !job.StartedAt.Valid && + !job.CanceledAt.Valid && + job.CompletedAt.Valid && + time.Since(job.UpdatedAt) > 30*time.Second || + build.Transition != database.WorkspaceTransitionStop { + continue + } + + case "stopped": + if !job.CompletedAt.Valid && + job.CanceledAt.Valid && + job.Error.Valid || + build.Transition != database.WorkspaceTransitionStop { + continue + } + + case "failed": + if (!job.CanceledAt.Valid && !job.Error.Valid) || + (!job.CompletedAt.Valid && !job.Error.Valid) { + continue + } + + case "canceling": + if !job.CanceledAt.Valid && job.CompletedAt.Valid { + continue + } + + case "canceled": + if !job.CanceledAt.Valid && !job.CompletedAt.Valid { + continue + } + + case "deleted": + if !job.StartedAt.Valid && + job.CanceledAt.Valid && + !job.CompletedAt.Valid && + time.Since(job.UpdatedAt) > 30*time.Second || + build.Transition != database.WorkspaceTransitionDelete { + continue + } + + case "deleting": + if !job.CompletedAt.Valid && + job.CanceledAt.Valid && + job.Error.Valid && + build.Transition != database.WorkspaceTransitionDelete { + continue + } + + default: + return 0, xerrors.Errorf("unknown workspace status in filter: %q", arg.Status) + } + } + + if len(arg.TemplateIds) > 0 { + match := false + for _, id := range arg.TemplateIds { + if workspace.TemplateID == id { + match = true + break + } + } + if !match { + continue + } + } + + // If the filter exists, ensure the object is authorized. + if authorizedFilter != nil && !authorizedFilter.Eval(workspace.RBACObject()) { + continue + } + workspaces = append(workspaces, workspace) + } + + return int64(len(workspaces)), nil +} + func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 3383b6af96e39..827287de1b1fd 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -159,6 +159,7 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([ type workspaceQuerier interface { GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error) + GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) } // GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. @@ -213,3 +214,53 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa } return items, nil } + +func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { + // In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the + // authorizedFilter between the end of the where clause and those statements. + filter := strings.Replace(getWorkspaces, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) + // The name comment is for metric tracking + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s", filter) + rows, err := q.db.QueryContext(ctx, query, + arg.Deleted, + arg.Status, + arg.OwnerID, + arg.OwnerUsername, + arg.TemplateName, + pq.Array(arg.TemplateIds), + arg.Name, + arg.Offset, + arg.Limit, + ) + if err != nil { + return 0, xerrors.Errorf("get authorized workspaces: %w", err) + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + &i.LastUsedAt, + ); err != nil { + return 0, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return 0, err + } + if err := rows.Err(); err != nil { + return 0, err + } + return int64(len(items)), nil +} From 1b142b45e7cc369d1a2a1cffc30719b9554c5e58 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 17 Oct 2022 19:51:01 +0000 Subject: [PATCH 11/42] SQL query --- coderd/database/queries/workspaces.sql | 129 +++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 6e7f0436dbff8..15e35391f7ed7 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -145,6 +145,135 @@ OFFSET @offset_ ; +-- this duplicates the filtering in GetWorkspaces +-- name: GetWorkspaceCount :one +SELECT + COUNT(*) as count +FROM + workspaces +LEFT JOIN LATERAL ( + SELECT + workspace_builds.transition, + provisioner_jobs.started_at, + provisioner_jobs.updated_at, + provisioner_jobs.canceled_at, + provisioner_jobs.completed_at, + provisioner_jobs.error + FROM + workspace_builds + LEFT JOIN + provisioner_jobs + ON + provisioner_jobs.id = workspace_builds.job_id + WHERE + workspace_builds.workspace_id = workspaces.id + ORDER BY + build_number DESC + LIMIT + 1 +) latest_build ON TRUE +WHERE + -- Optionally include deleted workspaces + workspaces.deleted = @deleted + AND CASE + WHEN @status :: text != '' THEN + CASE + WHEN @status = 'pending' THEN + latest_build.started_at IS NULL + WHEN @status = 'starting' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'start'::workspace_transition + + WHEN @status = 'running' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'start'::workspace_transition + + WHEN @status = 'stopping' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'stop'::workspace_transition + + WHEN @status = 'stopped' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'stop'::workspace_transition + + WHEN @status = 'failed' THEN + (latest_build.canceled_at IS NOT NULL AND + latest_build.error IS NOT NULL) OR + (latest_build.completed_at IS NOT NULL AND + latest_build.error IS NOT NULL) + + WHEN @status = 'canceling' THEN + latest_build.canceled_at IS NOT NULL AND + latest_build.completed_at IS NULL + + WHEN @status = 'canceled' THEN + latest_build.canceled_at IS NOT NULL AND + latest_build.completed_at IS NOT NULL + + WHEN @status = 'deleted' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NOT NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'delete'::workspace_transition + + WHEN @status = 'deleting' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'delete'::workspace_transition + + ELSE + true + END + ELSE true + END + -- Filter by owner_id + AND CASE + WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN + owner_id = @owner_id + ELSE true + END + -- Filter by owner_name + AND CASE + WHEN @owner_username :: text != '' THEN + owner_id = (SELECT id FROM users WHERE lower(username) = lower(@owner_username) AND deleted = false) + ELSE true + END + -- Filter by template_name + -- There can be more than 1 template with the same name across organizations. + -- Use the organization filter to restrict to 1 org if needed. + AND CASE + WHEN @template_name :: text != '' THEN + template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower(@template_name) AND deleted = false) + ELSE true + END + -- Filter by template_ids + AND CASE + WHEN array_length(@template_ids :: uuid[], 1) > 0 THEN + template_id = ANY(@template_ids) + ELSE true + END + -- Filter by name, matching on substring + AND CASE + WHEN @name :: text != '' THEN + name ILIKE '%' || @name || '%' + ELSE true + END + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceCount + -- @authorize_filter +; + -- name: GetWorkspaceByOwnerIDAndName :one SELECT * From ea9f240445d9a4a293cdb74b4f2f1224850e9767 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 17 Oct 2022 20:50:45 +0000 Subject: [PATCH 12/42] Fix params type --- coderd/database/databasefake/databasefake.go | 2 +- coderd/workspaces.go | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index a25f35e604251..e7a2fbd363b4d 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -717,7 +717,7 @@ func (q *fakeQuerier) GetWorkspaceCount(ctx context.Context, arg database.GetWor } //nolint:gocyclo -func (q *fakeQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg database.GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { +func (q *fakeQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg database.GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 614a7ba905b35..e089673597ccc 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -185,7 +185,17 @@ func (api *API) workspaceCount(rw http.ResponseWriter, r *http.Request) { return } - count, err := api.Database.GetAuthorizedWorkspaceCount(ctx, filter, sqlFilter) + countFilter := database.GetWorkspaceCountParams{ + Deleted: filter.Deleted, + OwnerUsername: filter.OwnerUsername, + OwnerID: filter.OwnerID, + Name: filter.Name, + Status: filter.Status, + TemplateIds: filter.TemplateIds, + TemplateName: filter.TemplateName, + } + + count, err := api.Database.GetAuthorizedWorkspaceCount(ctx, countFilter, sqlFilter) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace count.", From 09791c74f84df8c014a2a8c6632ca4bc2cf8045e Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Mon, 17 Oct 2022 20:52:18 +0000 Subject: [PATCH 13/42] Missed a spot --- coderd/database/modelqueries.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 827287de1b1fd..68ca0e9fdfbb2 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -215,7 +215,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa return items, nil } -func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { +func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { // In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the // authorizedFilter between the end of the where clause and those statements. filter := strings.Replace(getWorkspaces, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) From 32168a54db254dbfcde0dc0bb0ddea042e016ecb Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 18 Oct 2022 04:06:09 +0000 Subject: [PATCH 14/42] Space after alert banner --- site/src/pages/WorkspacesPage/WorkspacesPageView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index a016e1fd20590..83d5bb0be791a 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -85,6 +85,7 @@ export const WorkspacesPageView: FC< + + Date: Tue, 18 Oct 2022 04:13:00 +0000 Subject: [PATCH 15/42] Fix model queries --- coderd/database/modelqueries.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 68ca0e9fdfbb2..5c5ec14051cb6 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -218,9 +218,9 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { // In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the // authorizedFilter between the end of the where clause and those statements. - filter := strings.Replace(getWorkspaces, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) + filter := strings.Replace(getWorkspaceCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) // The name comment is for metric tracking - query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s", filter) + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceCount :many\n%s", filter) rows, err := q.db.QueryContext(ctx, query, arg.Deleted, arg.Status, @@ -229,8 +229,6 @@ func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWor arg.TemplateName, pq.Array(arg.TemplateIds), arg.Name, - arg.Offset, - arg.Limit, ) if err != nil { return 0, xerrors.Errorf("get authorized workspaces: %w", err) From 5eea6397f97b16223aa307c947716a55bfa0e876 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 18 Oct 2022 14:45:43 +0000 Subject: [PATCH 16/42] Unpack query correctly --- coderd/database/modelqueries.go | 38 +++++---------------------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 5c5ec14051cb6..f49c2b894ffe0 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -220,8 +220,8 @@ func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWor // authorizedFilter between the end of the where clause and those statements. filter := strings.Replace(getWorkspaceCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) // The name comment is for metric tracking - query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceCount :many\n%s", filter) - rows, err := q.db.QueryContext(ctx, query, + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceCount :one\n%s", filter) + row := q.db.QueryRowContext(ctx, query, arg.Deleted, arg.Status, arg.OwnerID, @@ -230,35 +230,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWor pq.Array(arg.TemplateIds), arg.Name, ) - if err != nil { - return 0, xerrors.Errorf("get authorized workspaces: %w", err) - } - defer rows.Close() - var items []Workspace - for rows.Next() { - var i Workspace - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.OwnerID, - &i.OrganizationID, - &i.TemplateID, - &i.Deleted, - &i.Name, - &i.AutostartSchedule, - &i.Ttl, - &i.LastUsedAt, - ); err != nil { - return 0, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return 0, err - } - if err := rows.Err(); err != nil { - return 0, err - } - return int64(len(items)), nil + var count int64 + err := row.Scan(&count) + return count, err } From ef7f59d5ffb985c96647fe77a69a4e4c915ea283 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 18 Oct 2022 15:05:35 +0000 Subject: [PATCH 17/42] Fix filter-page interaction --- .../pages/WorkspacesPage/WorkspacesPage.tsx | 2 +- .../workspaces/workspacesXService.ts | 237 +++++++++--------- 2 files changed, 121 insertions(+), 118 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index b12ae2c7bd6fb..b12f4d06dd101 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -65,7 +65,7 @@ const WorkspacesPage: FC = () => { onFilter={(query) => { setSearchParams({ filter: query }) send({ - type: "GET_WORKSPACES", + type: "UPDATE_FILTER", query, }) }} diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index 822ae8b0a9995..36add216517c6 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -213,149 +213,149 @@ interface WorkspacesContext { } type WorkspacesEvent = - | { type: "GET_WORKSPACES"; query?: string } + | { type: "UPDATE_FILTER"; query?: string } | { type: "UPDATE_VERSION"; workspaceId: string } | { type: "NEXT" } | { type: "PREVIOUS" } | { type: "GO_TO_PAGE"; page: number } export const workspacesMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QHcD2AnA1rADgQwGM4BlAFz1LADpk8BLUgFVQCUwAzdOACwHUNs+IrADEAD1jlKVPO0roAFAFYADGoCUItFlyESU6rQbM2nHvx1C4AbRUBdRKBypYDOqgB2jkGMQBGAGYANgCqFQAmAE4-cIAOOPDwoIB2ZIAaEABPRHCVKljAv2S-EMiClSUAFliAXxqM7UE9WDIKanYwUgJuOg8oKgJUAFcPUioYUlJeqABhYdGRCE9qXoA3VExqCYsm4TmR0lsHJBBnVynPb18ESsq8uOLk2KCVSqVY4qUM7IQAyqoApElOFKskgpEUn5avUQI1dMJWtIOl0en0BvMxhMpn19gswOh0BgqDgADYUdgYAC2406O3hcFxh3s3jObkuJ2ufyUVEiAXi4QCySUAWBARF3xyaiowuqZShQSCiUidQaAnpLQMVGR3WmNDVVlgVCGOAgFGmdKsplESw8Kw8602RpNbQteitRxZLjZXg5iFitwByTKARUQVulQCiQlCBeeX9sWifihhXBKth+uaiPanR1aLhBppk3NGeEi2WVDWGy2tJLNmZJ1ZFx9oGub25f0jyQqSiUQvi0aUiqoiXCfnecT8kRUwTT+czmu1qP6c+EhexUFdpZtdod1dIm5sfmOTi9TauiFukWlCb5097-tD0cqQ57dw+8cCz9ntY1bS1OaXPVLGaNdi2A0t8UJdBiTJUgKXQalth-D0G1Pdxmx8fximHF5O1uVJKgFAJoxCZIqChPlIhBUMngib9wP0P9F2mMtbSoSQ-xXRikQA6YUJPc50PPBBAhSYcQWBJJcmBfsshyIpxNHLsCiUIFIyCejdm4sARAAcQAUUYAB9XgAHkWAAaWIAAFABBGZ9OIfjTjQ9kW0QABaQiwmKadkhDQjgmSSpow8gIx3Ivkg17KIwSUPxNPVLMRAAVWsgARWzGH0oyADV9JYYgAElTIAOWcxshN9BB4jIsdhReSpIgjJrwlCkU-CoYKlQqQVYniRKDWS0r9IADUYCrXIw64Sm5XsFSSHtR1BQJ2r8f5Xg+KIww+YogiUQb5zaERrJYfTcpKlKnPrATvWEwV-nBUdR0HZqRTDNaNuqZJtu+vaDphLjf0oPTTKMxgwbsgzJsEtzMIQd4gi6ypRNBO4BWeT6wm+37dtmuoYQ8VAIDgbwgazGh6CYVgOC4WA+B-T1Yem-wxUe3sij6uIOujUSeSCKF-MFXlAmBQ6EQXXi0UGA5QJxDEmbu6q-mHVT+oTEIYkIkK5N+K9ARUeIIlHNQRUqcXtP-FFdRl0YqG3RWz2qkIkdeP5XmKEEFujUckZ+0dnj5DGewCC3geza3pYV1DmeEjzAmRlQkz8acgg+ebQqqKhGqFCNIkiIUkmhVUGPDq3c2XH8nVNdcDytR2qvcmrIx5TmhUBJIk6+XXbliLrB369SDZTgGS60svmLzKusTA8eG7hzl-l7YoqKIxUShKJ83n7tOnnC7bwkHMOKcnyvS-t5Z55ZhBin+ajfIxsUIWjfy5pSXlFVDAfQ8Bn8T6ls+c8Y5KybvHUIhE4gqFSAqPqfIdY-HejvWIlFqh8gesfSWkcoBXzjgLROydU7pzBKFdaoQc6XjBOGEUGC2g4OqvHFQV5gpJyTIQoUxDdZeVeJFD4zUIypGKD-OoQA */ + /** @xstate-layout N4IgpgJg5mDOIC5QHcD2AnA1rADgQwGM4BlAFz1LADpk8BLUgFVQCUwAzdOACwHUNs+IrADEAD1jlKVPO0roAFAFYADGoCUItFlyESU6rQbM2nHvx1C4AbRUBdRKBypYDOqgB2jkGMQBGAGYANgCqFQAmAE4-cIAOOPDwoIB2ZIAaEABPRHCVKljAv2S-EMiClSUAFliAXxqM7UE9WDIKanYwUgJuOg8oKgJUAFcPUioYUlJeqABhYdGRCE9qXoA3VExqCYsm4TmR0lsHJBBnVynPb18ESsq8uOLk2KCVSqVY4qUM7IQAyqoApElOFKskgpEUn5avUQI1dMJWtIOl0en0BvMxhMpn19gswOh0BgqDgADYUdgYAC2406O3hcFxh3s3jObkuJ2ufyUVEiAXi4QCySUAWBARF3xyaiowuqZShQSCiUidQaAnpLQMVGR3WmNDVVlgVCGOAgFGmdKsplESw8Kw8602RpNbQteitRxZLjZXg5iFitwByTKARUQVulQCiQlCBeeX9sWifihhXBKth+uaiPanR1aLhBppk3NGeEi2WVDWGy2tJLNmZJ1ZFx9oGub25f0jyQqSiUQvi0aUiqoiXCfnecT8kRUwTT+czmu1qP6c+EhexUFdpZtdod1dIm5sfmOTi9TauiFukWlCb5097-tD0cqQ57dw+8cCz9ntY1bS1OaXPVLGaNdi2A0t8UJdBiTJUgKXQalth-D0G1Pdxmx8fximHF5O1uVJKgFAJoxCZIqChPlIhBUMngib9wP0P9F2mMtbSoSQ-xXRikQA6YUJPc50PPBBAhSYcQWBJJcmBfsshyIpxNHLsCiUIFIyCejdm4sARAAcQAUUYAB9XgAHkWAAaWIAAFABBGZ9OIfjTjQ9kW0QABaQiwmKadkhDQjgmSSpow8gIx3Ivkg17KIwSUPxNPVLMRAAVWsgARWzGH0oyADEAEkABlspYZzGyE30ECePwAVicLkiiJNKjHcJQvC-5Xg+Io+XHYFEoNZK0sy7KjIANX0lhiHy0yADkytcjDrj8NQyNHBUilUYplsiNrvJFJJVEHZqew0mEuN-SgRBm-SAA1GHmwS3MwkSgm5XsFQO4E-FBQI2u+sJqgaiFAeKV7+vnNoRGslh9NG6aUqc+sBO9YTBX+cFR1HQdIgjI6-o6wGojDD5QaUcGEQMPTTKMxhqbsgyHpRyr3iCKhgtE0E7gFZ58YBj4iZBkoybTDxUAgOBvHOrMaHoJhWA4LhYD4H9PUexb-DFdHe26p44hFPxo1EnkgihfzBV5QI+rOn9peYtFBgOUCcQxVWmfc35-nCVTYh9iFwpBEFo0BAEp3iCJRzUEVKnJ7T-xRXUHdGKht1ds9KpCVnXj+V5ihBD7ozWtnEnlPluZ7AIY4u7N4-tl3ULV4SPMCNm7iTZbgg+d7QqqKgXlBYIIxUSIcd5Svbd4vMfydU11wPK1U4q926vCHkdeFKiXjHJ9qjZwcffUwEVGW4XVQYqu49zZcp6xMCtPgeu3eev5pVSSdEjFRUShKbfuSFIJ339hCL2p1T533HjXK+Z9k7LAXk9a4xRPZ3F8tzMUEJoz+TeikXkipQx7wrtbM+4DL5ATvrA9WCAm6hEInEFQqQFSCjqv6IOg5d7-zeIEP4pQx4LgnlAMhjcTYtyPkmac-8hRglCt9UIfcGrdg+LQj43C2j8Mqk3IeQi26iM7hIuSFC7j-ECD7LOdVBRDzqHUIAA */ createMachine( { - tsTypes: {} as import("./workspacesXService.typegen").Typegen1, - schema: { - context: {} as WorkspacesContext, - events: {} as WorkspacesEvent, - services: {} as { - getWorkspaces: { - data: TypesGen.Workspace[] - } - getWorkspacesCount: { - data: { count: number } - } - updateWorkspaceRefs: { - data: { - refsToKeep: WorkspaceItemMachineRef[] - newWorkspaces: TypesGen.Workspace[] - } - } - }, - }, - predictableActionArguments: true, - id: "workspacesState", - on: { - GET_WORKSPACES: { - target: ".fetching", - actions: "assignFilter", - }, - UPDATE_VERSION: { - actions: "triggerUpdateVersion", - }, - NEXT: { - target: ".fetching", - actions: ["assignNextPage", "onPageChange"], - }, - PREVIOUS: { - target: ".fetching", - actions: ["assignPreviousPage", "onPageChange"], - }, - GO_TO_PAGE: { - target: ".fetching", - actions: ["assignPage", "onPageChange"], + tsTypes: {} as import("./workspacesXService.typegen").Typegen1, + schema: { + context: {} as WorkspacesContext, + events: {} as WorkspacesEvent, + services: {} as { + getWorkspaces: { + data: TypesGen.Workspace[] + } + getWorkspacesCount: { + data: { count: number } + } + updateWorkspaceRefs: { + data: { + refsToKeep: WorkspaceItemMachineRef[] + newWorkspaces: TypesGen.Workspace[] + } + } + }, + }, + predictableActionArguments: true, + id: "workspacesState", + on: { + UPDATE_FILTER: { + target: ".fetching", + actions: ["assignFilter", "resetPage"], + }, + UPDATE_VERSION: { + actions: "triggerUpdateVersion", + }, + NEXT: { + target: ".fetching", + actions: ["assignNextPage", "onPageChange"], + }, + PREVIOUS: { + target: ".fetching", + actions: ["assignPreviousPage", "onPageChange"], + }, + GO_TO_PAGE: { + target: ".fetching", + actions: ["assignPage", "onPageChange"], + }, + }, + initial: "fetching", + states: { + waitToRefreshWorkspaces: { + after: { + "5000": { + target: "#workspacesState.fetching", + actions: [], + internal: false, }, }, - initial: "fetching", + }, + fetching: { + type: "parallel", states: { - waitToRefreshWorkspaces: { - after: { - "5000": { - target: "#workspacesState.fetching", - actions: [], - internal: false, + count: { + initial: "gettingCount", + states: { + gettingCount: { + entry: "clearGetCountError", + invoke: { + src: "getWorkspacesCount", + id: "getWorkspacesCount", + onDone: [ + { + target: "done", + actions: "assignCount", + }, + ], + onError: [ + { + target: "done", + actions: "assignGetCountError", + }, + ], + }, + }, + done: { + type: "final", }, }, }, - fetching: { - type: "parallel", + workspaces: { + initial: "gettingWorkspaces", states: { - count: { - initial: "gettingCount", - states: { - gettingCount: { - entry: "clearGetCountError", - invoke: { - src: "getWorkspacesCount", - id: "getWorkspacesCount", - onDone: [ - { - target: "done", - actions: "assignCount", - }, - ], - onError: [ - { - target: "done", - actions: "assignGetCountError", - }, - ], + updatingWorkspaceRefs: { + invoke: { + src: "updateWorkspaceRefs", + id: "updateWorkspaceRefs", + onDone: [ + { + target: "done", + actions: "assignUpdatedWorkspaceRefs", }, - }, - done: { - type: "final", - }, + ], }, }, - workspaces: { - initial: "gettingWorkspaces", - states: { - updatingWorkspaceRefs: { - invoke: { - src: "updateWorkspaceRefs", - id: "updateWorkspaceRefs", - onDone: [ - { - target: "done", - actions: "assignUpdatedWorkspaceRefs", - }, - ], + gettingWorkspaces: { + entry: "clearGetWorkspacesError", + invoke: { + src: "getWorkspaces", + id: "getWorkspaces", + onDone: [ + { + target: "done", + cond: "isEmpty", + actions: "assignWorkspaceRefs", }, - }, - gettingWorkspaces: { - entry: "clearGetWorkspacesError", - invoke: { - src: "getWorkspaces", - id: "getWorkspaces", - onDone: [ - { - target: "done", - cond: "isEmpty", - actions: "assignWorkspaceRefs", - }, - { - target: "updatingWorkspaceRefs", - }, - ], - onError: [ - { - target: "done", - actions: "assignGetWorkspacesError", - }, - ], + { + target: "updatingWorkspaceRefs", }, - }, - done: { - type: "final", - }, + ], + onError: [ + { + target: "done", + actions: "assignGetWorkspacesError", + }, + ], }, }, - }, - onDone: { - target: "waitToRefreshWorkspaces", + done: { + type: "final", + }, }, }, }, + onDone: { + target: "waitToRefreshWorkspaces", + }, }, + }, +}, { guards: { isEmpty: (context) => !context.workspaceRefs, @@ -406,6 +406,9 @@ export const workspacesMachine = assignPage: assign({ page: (_, event) => event.page, }), + resetPage: assign({ + page: (_) => 1 + }), assignCount: assign({ count: (_, event) => event.data.count, }), From eae13a272acefd6d1fa160efc3f44b28a7b9e995 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 18 Oct 2022 15:36:50 +0000 Subject: [PATCH 18/42] Make mobile friendly --- .../PaginationWidget/PaginationWidget.tsx | 98 ++++++++++++++----- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx index ba963aed92921..b8d4ba10a4009 100644 --- a/site/src/components/PaginationWidget/PaginationWidget.tsx +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -1,7 +1,10 @@ import Button from "@material-ui/core/Button" -import { makeStyles } from "@material-ui/core/styles" +import { makeStyles, useTheme } from "@material-ui/core/styles" +import useMediaQuery from "@material-ui/core/useMediaQuery" import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft" import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { Maybe } from "components/Conditionals/Maybe" import { CSSProperties } from "react" export type PaginationWidgetProps = { @@ -74,6 +77,39 @@ export const buildPagedList = ( return range(1, numPages) } +interface PageButtonProps { + activePage: number + page: number + numPages: number + onPageClick?: (page: number) => void +} + +const PageButton = ({ + activePage, + page, + numPages, + onPageClick, +}: PageButtonProps): JSX.Element => { + const styles = useStyles() + return ( + + ) +} + export const PaginationWidget = ({ prevLabel, nextLabel, @@ -88,7 +124,8 @@ export const PaginationWidget = ({ const numPages = numRecords ? Math.ceil(numRecords / numRecordsPerPage) : 0 const firstPageActive = activePage === 1 && numPages !== 0 const lastPageActive = activePage === numPages && numPages !== 0 - + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) const styles = useStyles() // No need to display any pagination if we know the number of pages is 1 @@ -107,30 +144,39 @@ export const PaginationWidget = ({
{prevLabel}
- {numPages > 0 && - buildPagedList(numPages, activePage).map((page) => - typeof page !== "number" ? ( - - ) : ( - - ), - )} + 0}> + + + + + + {buildPagedList(numPages, activePage).map((page) => + typeof page !== "number" ? ( + + ) : ( + + ), + )} + + + ) : ( Date: Tue, 18 Oct 2022 19:38:53 +0000 Subject: [PATCH 22/42] Delete unnecessary conditional --- site/src/pages/WorkspacesPage/WorkspacesPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 5f519faff68de..42f72fd696304 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -114,7 +114,7 @@ export const WorkspacesPageView: FC< filter={filter} /> - limit}> + Date: Wed, 19 Oct 2022 02:41:40 +0000 Subject: [PATCH 23/42] Add test helpers --- site/src/testHelpers/entities.ts | 14 ++++++++++++++ site/src/testHelpers/handlers.ts | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index fe779f3789bdf..163eceba0fce0 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -418,10 +418,12 @@ export const MockWorkspace: TypesGen.Workspace = { export const MockStoppedWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-stopped-workspace", latest_build: { ...MockWorkspaceBuildStop, status: "stopped" }, } export const MockStoppingWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-stopping-workspace", latest_build: { ...MockWorkspaceBuildStop, job: MockRunningProvisionerJob, @@ -430,6 +432,7 @@ export const MockStoppingWorkspace: TypesGen.Workspace = { } export const MockStartingWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-starting-workspace", latest_build: { ...MockWorkspaceBuild, job: MockRunningProvisionerJob, @@ -439,6 +442,7 @@ export const MockStartingWorkspace: TypesGen.Workspace = { } export const MockCancelingWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-canceling-workspace", latest_build: { ...MockWorkspaceBuild, job: MockCancelingProvisionerJob, @@ -447,6 +451,7 @@ export const MockCancelingWorkspace: TypesGen.Workspace = { } export const MockCanceledWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-canceled-workspace", latest_build: { ...MockWorkspaceBuild, job: MockCanceledProvisionerJob, @@ -455,6 +460,7 @@ export const MockCanceledWorkspace: TypesGen.Workspace = { } export const MockFailedWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-failed-workspace", latest_build: { ...MockWorkspaceBuild, job: MockFailedProvisionerJob, @@ -463,6 +469,7 @@ export const MockFailedWorkspace: TypesGen.Workspace = { } export const MockDeletingWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-deleting-workspace", latest_build: { ...MockWorkspaceBuildDelete, job: MockRunningProvisionerJob, @@ -471,16 +478,19 @@ export const MockDeletingWorkspace: TypesGen.Workspace = { } export const MockDeletedWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-deleted-workspace", latest_build: { ...MockWorkspaceBuildDelete, status: "deleted" }, } export const MockOutdatedWorkspace: TypesGen.Workspace = { ...MockFailedWorkspace, + id: "test-outdated-workspace", outdated: true, } export const MockPendingWorkspace: TypesGen.Workspace = { ...MockWorkspace, + id: "test-pending-workspace", latest_build: { ...MockWorkspaceBuild, job: MockPendingProvisionerJob, @@ -496,6 +506,10 @@ export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { template_id: "test-template", } +export const MockWorkspaceCountResponse: TypesGen.WorkspaceCountResponse = { + count: 26 // just over 1 page +} + export const MockUserAgent: Types.UserAgent = { browser: "Chrome 99.0.4844", device: "Other", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index fd6903cad88a7..4f17fa4ce9aa8 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -140,6 +140,10 @@ export const handlers = [ rest.get("/api/v2/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json([M.MockWorkspace])) }), + // has to come before the parameterized endpoints + rest.get("/api/v2/workspaces/count", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockWorkspaceCountResponse)) + }), rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), From d8387897d0430c8f52462510197781dd863ad6ab Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 02:53:56 +0000 Subject: [PATCH 24/42] Use limit constant --- site/src/components/PaginationWidget/PaginationWidget.tsx | 2 +- site/src/pages/WorkspacesPage/WorkspacesPage.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx index c7db31128807d..b421de929cbec 100644 --- a/site/src/components/PaginationWidget/PaginationWidget.tsx +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -27,7 +27,7 @@ export type PaginationWidgetProps = { const range = (start: number, stop: number, step = 1) => Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step) -const DEFAULT_RECORDS_PER_PAGE = 25 +export const DEFAULT_RECORDS_PER_PAGE = 25 // Number of pages to the left or right of the current page selection. const PAGE_NEIGHBORS = 1 // Number of pages displayed for cases where there are multiple ellipsis showing. This can be diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index b12f4d06dd101..18a13b64d49ab 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,4 +1,5 @@ import { useMachine } from "@xstate/react" +import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/PaginationWidget" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useNavigate, useSearchParams } from "react-router-dom" @@ -17,7 +18,7 @@ const WorkspacesPage: FC = () => { const [workspacesState, send] = useMachine(workspacesMachine, { context: { page: currentPage, - limit: 25, + limit: DEFAULT_RECORDS_PER_PAGE, filter, }, actions: { From 2efe49b4caaf4811020cf84fb1ba47e6557b76d2 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 03:37:40 +0000 Subject: [PATCH 25/42] Show widget with no count --- .../WorkspacesPage/WorkspacesPageView.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 42f72fd696304..3ab6829928aa8 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -114,18 +114,16 @@ export const WorkspacesPageView: FC< filter={filter} /> - - - + ) } From 950ac50193207f637f3d2028e4c74fa274a9d16e Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 03:37:51 +0000 Subject: [PATCH 26/42] Add test --- site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 57cd68b31b0c9..109f83edd1476 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -1,4 +1,5 @@ import { screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { rest } from "msw" import * as CreateDayString from "util/createDayString" import { Language as WorkspacesTableBodyLanguage } from "../../components/WorkspacesTable/WorkspacesTableBody" @@ -39,4 +40,14 @@ describe("WorkspacesPage", () => { // Then await screen.findByText(MockWorkspace.name) }) + + it("navigates to the next page of workspaces", async () => { + const user = userEvent.setup() + const { container } = render() + const nextPage = await screen.findByRole("button", { name: "Next page" }) + expect(nextPage).toBeEnabled() + await user.click(nextPage) + const pageButtons = await container.querySelectorAll(`button[name="Page button"]`) + expect(pageButtons.length).toBe(2) + }) }) From 296281d99f64f1de57c61ea33f137c7790581ff4 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 03:41:53 +0000 Subject: [PATCH 27/42] Format --- site/src/components/PaginationWidget/PaginationWidget.tsx | 6 ++---- site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx | 4 +++- site/src/testHelpers/entities.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx index b421de929cbec..7018e6a5acb59 100644 --- a/site/src/components/PaginationWidget/PaginationWidget.tsx +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -124,7 +124,7 @@ export const PaginationWidget = ({ const firstPageActive = activePage === 1 && numPages !== 0 const lastPageActive = activePage === numPages && numPages !== 0 const theme = useTheme() - const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + const isMobile = useMediaQuery(theme.breakpoints.down("sm")) const styles = useStyles() // No need to display any pagination if we know the number of pages is 1 @@ -145,9 +145,7 @@ export const PaginationWidget = ({ 0}> - + { const nextPage = await screen.findByRole("button", { name: "Next page" }) expect(nextPage).toBeEnabled() await user.click(nextPage) - const pageButtons = await container.querySelectorAll(`button[name="Page button"]`) + const pageButtons = await container.querySelectorAll( + `button[name="Page button"]`, + ) expect(pageButtons.length).toBe(2) }) }) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 163eceba0fce0..e9b87440e3f93 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -507,7 +507,7 @@ export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { } export const MockWorkspaceCountResponse: TypesGen.WorkspaceCountResponse = { - count: 26 // just over 1 page + count: 26, // just over 1 page } export const MockUserAgent: Types.UserAgent = { From 78c231d796f6d965301e6a5b1dc9e17570b4665e Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 19 Oct 2022 14:20:08 +0000 Subject: [PATCH 28/42] make gen from garretts workspace idk why --- coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 154 +++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 393ab81fdd347..9d594ca5f0313 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -109,6 +109,8 @@ type sqlcQuerier interface { GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) + // this duplicates the filtering in GetWorkspaces + GetWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams) (int64, error) GetWorkspaceCountByUserID(ctx context.Context, ownerID uuid.UUID) (int64, error) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cb4b43591a41e..70b604647dc1c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5697,6 +5697,160 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo return i, err } +const getWorkspaceCount = `-- name: GetWorkspaceCount :one +SELECT + COUNT(*) as count +FROM + workspaces +LEFT JOIN LATERAL ( + SELECT + workspace_builds.transition, + provisioner_jobs.started_at, + provisioner_jobs.updated_at, + provisioner_jobs.canceled_at, + provisioner_jobs.completed_at, + provisioner_jobs.error + FROM + workspace_builds + LEFT JOIN + provisioner_jobs + ON + provisioner_jobs.id = workspace_builds.job_id + WHERE + workspace_builds.workspace_id = workspaces.id + ORDER BY + build_number DESC + LIMIT + 1 +) latest_build ON TRUE +WHERE + -- Optionally include deleted workspaces + workspaces.deleted = $1 + AND CASE + WHEN $2 :: text != '' THEN + CASE + WHEN $2 = 'pending' THEN + latest_build.started_at IS NULL + WHEN $2 = 'starting' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'start'::workspace_transition + + WHEN $2 = 'running' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'start'::workspace_transition + + WHEN $2 = 'stopping' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'stop'::workspace_transition + + WHEN $2 = 'stopped' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'stop'::workspace_transition + + WHEN $2 = 'failed' THEN + (latest_build.canceled_at IS NOT NULL AND + latest_build.error IS NOT NULL) OR + (latest_build.completed_at IS NOT NULL AND + latest_build.error IS NOT NULL) + + WHEN $2 = 'canceling' THEN + latest_build.canceled_at IS NOT NULL AND + latest_build.completed_at IS NULL + + WHEN $2 = 'canceled' THEN + latest_build.canceled_at IS NOT NULL AND + latest_build.completed_at IS NOT NULL + + WHEN $2 = 'deleted' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NOT NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'delete'::workspace_transition + + WHEN $2 = 'deleting' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'delete'::workspace_transition + + ELSE + true + END + ELSE true + END + -- Filter by owner_id + AND CASE + WHEN $3 :: uuid != '00000000-00000000-00000000-00000000' THEN + owner_id = $3 + ELSE true + END + -- Filter by owner_name + AND CASE + WHEN $4 :: text != '' THEN + owner_id = (SELECT id FROM users WHERE lower(username) = lower($4) AND deleted = false) + ELSE true + END + -- Filter by template_name + -- There can be more than 1 template with the same name across organizations. + -- Use the organization filter to restrict to 1 org if needed. + AND CASE + WHEN $5 :: text != '' THEN + template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($5) AND deleted = false) + ELSE true + END + -- Filter by template_ids + AND CASE + WHEN array_length($6 :: uuid[], 1) > 0 THEN + template_id = ANY($6) + ELSE true + END + -- Filter by name, matching on substring + AND CASE + WHEN $7 :: text != '' THEN + name ILIKE '%' || $7 || '%' + ELSE true + END + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceCount + -- @authorize_filter +` + +type GetWorkspaceCountParams struct { + Deleted bool `db:"deleted" json:"deleted"` + Status string `db:"status" json:"status"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"` + Name string `db:"name" json:"name"` +} + +// this duplicates the filtering in GetWorkspaces +func (q *sqlQuerier) GetWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceCount, + arg.Deleted, + arg.Status, + arg.OwnerID, + arg.OwnerUsername, + arg.TemplateName, + pq.Array(arg.TemplateIds), + arg.Name, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + const getWorkspaceCountByUserID = `-- name: GetWorkspaceCountByUserID :one SELECT COUNT(id) From fc5df6cbb1898a1af4f104093361f145d9a4c07e Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 19 Oct 2022 14:28:09 +0000 Subject: [PATCH 29/42] fix authorize test' --- coderd/coderdtest/authorize.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index a5183f2b6e450..8c172cd5f7a57 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -243,7 +243,8 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, // Endpoints that use the SQLQuery filter. - "GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true}, + "GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true}, + "GET:/api/v2/workspaces/count": {StatusCode: http.StatusOK, NoAuthorize: true}, } // Routes like proxy routes support all HTTP methods. A helper func to expand From bb2f0f368fd542e8f89b37ac0eef8150f5553032 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 15:16:41 +0000 Subject: [PATCH 30/42] Hide widget with 0 records --- site/src/components/PaginationWidget/PaginationWidget.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx index 7018e6a5acb59..bede3d090ca05 100644 --- a/site/src/components/PaginationWidget/PaginationWidget.tsx +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -27,7 +27,7 @@ export type PaginationWidgetProps = { const range = (start: number, stop: number, step = 1) => Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step) -export const DEFAULT_RECORDS_PER_PAGE = 25 +export const DEFAULT_RECORDS_PER_PAGE = 2 // Number of pages to the left or right of the current page selection. const PAGE_NEIGHBORS = 1 // Number of pages displayed for cases where there are multiple ellipsis showing. This can be @@ -128,7 +128,7 @@ export const PaginationWidget = ({ const styles = useStyles() // No need to display any pagination if we know the number of pages is 1 - if (numPages === 1) { + if (numPages === 1 || numRecords === 0) { return null } From 6174b4f10b3f476071e79db2ecb04676a849fa6d Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 16:03:13 +0000 Subject: [PATCH 31/42] Fix tests --- .../PaginationWidget/PaginationWidget.tsx | 2 +- .../WorkspacePage/WorkspacePage.test.tsx | 2 +- .../WorkspacesPage/WorkspacesPage.test.tsx | 25 ++++++++----------- .../deploymentFlags/deploymentFlagsMachine.ts | 1 + 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx index bede3d090ca05..f2dfbfb0900ed 100644 --- a/site/src/components/PaginationWidget/PaginationWidget.tsx +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -27,7 +27,7 @@ export type PaginationWidgetProps = { const range = (start: number, stop: number, step = 1) => Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step) -export const DEFAULT_RECORDS_PER_PAGE = 2 +export const DEFAULT_RECORDS_PER_PAGE = 25 // Number of pages to the left or right of the current page selection. const PAGE_NEIGHBORS = 1 // Number of pages displayed for cases where there are multiple ellipsis showing. This can be diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 3986d9930f4db..55c36b4042f76 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -220,7 +220,7 @@ describe("WorkspacePage", () => { await waitFor(() => expect(api.startWorkspace).toBeCalledWith( - "test-workspace", + "test-outdated-workspace", "test-template-version", ), ) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 960edfb6a795d..ec026deee31c5 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -1,5 +1,4 @@ -import { screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" +import { screen, waitFor } from "@testing-library/react" import { rest } from "msw" import * as CreateDayString from "util/createDayString" import { Language as WorkspacesTableBodyLanguage } from "../../components/WorkspacesTable/WorkspacesTableBody" @@ -35,21 +34,19 @@ describe("WorkspacesPage", () => { it("renders a filled workspaces page", async () => { // When - render() + const { container } = render() // Then - await screen.findByText(MockWorkspace.name) - }) - - it("navigates to the next page of workspaces", async () => { - const user = userEvent.setup() - const { container } = render() const nextPage = await screen.findByRole("button", { name: "Next page" }) expect(nextPage).toBeEnabled() - await user.click(nextPage) - const pageButtons = await container.querySelectorAll( - `button[name="Page button"]`, - ) - expect(pageButtons.length).toBe(2) + await waitFor(async () => { + const prevPage = await screen.findByRole("button", { name: "Previous page" }) + expect(prevPage).toBeDisabled() + const pageButtons = await container.querySelectorAll( + `button[name="Page button"]`, + ) + expect(pageButtons.length).toBe(2) + }, { timeout: 2000 }) + await screen.findByText(MockWorkspace.name) }) }) diff --git a/site/src/xServices/deploymentFlags/deploymentFlagsMachine.ts b/site/src/xServices/deploymentFlags/deploymentFlagsMachine.ts index 249c49e2b1a79..aa18e9d179ce1 100644 --- a/site/src/xServices/deploymentFlags/deploymentFlagsMachine.ts +++ b/site/src/xServices/deploymentFlags/deploymentFlagsMachine.ts @@ -5,6 +5,7 @@ import { createMachine, assign } from "xstate" export const deploymentFlagsMachine = createMachine( { id: "deploymentFlagsMachine", + predictableActionArguments: true, initial: "idle", schema: { context: {} as { From 3ab3505eef2747e61b58303f0d222ab7fa602ec1 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 16:05:41 +0000 Subject: [PATCH 32/42] Format --- .../WorkspacesPage/WorkspacesPage.test.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index ec026deee31c5..7ba6ef88ee4a4 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -39,14 +39,19 @@ describe("WorkspacesPage", () => { // Then const nextPage = await screen.findByRole("button", { name: "Next page" }) expect(nextPage).toBeEnabled() - await waitFor(async () => { - const prevPage = await screen.findByRole("button", { name: "Previous page" }) - expect(prevPage).toBeDisabled() - const pageButtons = await container.querySelectorAll( - `button[name="Page button"]`, - ) - expect(pageButtons.length).toBe(2) - }, { timeout: 2000 }) + await waitFor( + async () => { + const prevPage = await screen.findByRole("button", { + name: "Previous page", + }) + expect(prevPage).toBeDisabled() + const pageButtons = await container.querySelectorAll( + `button[name="Page button"]`, + ) + expect(pageButtons.length).toBe(2) + }, + { timeout: 2000 }, + ) await screen.findByText(MockWorkspace.name) }) }) From a20827cc4d446dcda5454e5d98f6e3443fe6c0ec Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 16:55:00 +0000 Subject: [PATCH 33/42] Fix types generated --- site/src/api/typesGenerated.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ec2da89fcef3a..bb715c130b046 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -896,6 +896,7 @@ export interface WorkspaceResourceMetadata { export interface WorkspacesRequest extends Pagination { readonly q?: string } + // From codersdk/apikey.go export type APIKeyScope = "all" | "application_connect" From c06765b7fb859f95972c99880f18ca0739295850 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 17:07:29 +0000 Subject: [PATCH 34/42] Fix story --- .../WorkspacesPageView.stories.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index f25607dda1f0c..9162e9de806e0 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -21,11 +21,14 @@ const createWorkspaceItemRef = ( transition: WorkspaceTransition = "start", outdated = false, lastUsedAt = "0001-01-01", + id?: string, ): WorkspaceItemMachineRef => { + const wsId = id ?? MockWorkspace.id return spawn( workspaceItemMachine.withContext({ data: { ...MockWorkspace, + id: wsId, outdated, latest_build: { ...MockWorkspace.latest_build, @@ -83,6 +86,18 @@ const additionalWorkspaces: Record = { ), } +const fillerWorkspaces = Array(14) + .fill(undefined) + .map((_, i) => + createWorkspaceItemRef( + "running", + undefined, + true, + dayjs().toString(), + `test-workspace-${i}`, + ), + ) + export default { title: "pages/WorkspacesPageView", component: WorkspacesPageView, @@ -94,6 +109,18 @@ export default { ], mapping: { ...workspaces, ...additionalWorkspaces }, }, + onFilter: { + action: "filter", + }, + onGoToPage: { + action: "go to page", + }, + onNext: { + action: "next", + }, + onPrevious: { + action: "previous", + }, }, } as ComponentMeta @@ -107,16 +134,29 @@ AllStates.args = { ...Object.values(workspaces), ...Object.values(additionalWorkspaces), ], + count: 14, } export const OwnerHasNoWorkspaces = Template.bind({}) OwnerHasNoWorkspaces.args = { workspaceRefs: [], + count: 0, filter: workspaceFilterQuery.me, } export const NoResults = Template.bind({}) NoResults.args = { workspaceRefs: [], + count: 0, filter: "searchtearmwithnoresults", } + +export const TwoPages = Template.bind({}) +TwoPages.args = { + workspaceRefs: [ + ...Object.values(workspaces), + ...Object.values(additionalWorkspaces), + ...fillerWorkspaces, + ], + count: 28, +} From 6bd96836144becbddc781e694be7283c494880e2 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 17:13:30 +0000 Subject: [PATCH 35/42] Add alert banner story --- site/src/components/AlertBanner/AlertBanner.stories.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx index d426d97393cd6..f6379b0e65d6d 100644 --- a/site/src/components/AlertBanner/AlertBanner.stories.tsx +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -99,3 +99,9 @@ ErrorWithActionRetryAndDismiss.args = { dismissible: true, severity: "error", } + +export const ErrorAsWarning = Template.bind({}) +ErrorAsWarning.args = { + error: mockError, + severity: "warning" +} From de2ed631a518bc9e154fc1a4543025dca5f79beb Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 17:16:04 +0000 Subject: [PATCH 36/42] Format --- site/src/components/AlertBanner/AlertBanner.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx index f6379b0e65d6d..536d0c6c885f0 100644 --- a/site/src/components/AlertBanner/AlertBanner.stories.tsx +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -103,5 +103,5 @@ ErrorWithActionRetryAndDismiss.args = { export const ErrorAsWarning = Template.bind({}) ErrorAsWarning.args = { error: mockError, - severity: "warning" + severity: "warning", } From ff8cb81ff8ce8ebd7b0b797b0feaa27c1e878f8e Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 18:19:19 +0000 Subject: [PATCH 37/42] Fix import --- site/src/components/AlertBanner/AlertBanner.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx index 536d0c6c885f0..40bb712d6b3c7 100644 --- a/site/src/components/AlertBanner/AlertBanner.stories.tsx +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -1,7 +1,8 @@ import { Story } from "@storybook/react" -import { AlertBanner, AlertBannerProps } from "./AlertBanner" +import { AlertBanner } from "./AlertBanner" import Button from "@material-ui/core/Button" import { makeMockApiError } from "testHelpers/entities" +import { AlertBannerProps } from "./alertTypes" export default { title: "components/AlertBanner", From e609e5a2cc58f5ebb020413c0949630827186616 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 18:21:20 +0000 Subject: [PATCH 38/42] Format --- site/src/components/AlertBanner/AlertBanner.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx index 40bb712d6b3c7..10eebd8ed810c 100644 --- a/site/src/components/AlertBanner/AlertBanner.stories.tsx +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -1,5 +1,5 @@ import { Story } from "@storybook/react" -import { AlertBanner } from "./AlertBanner" +import { AlertBanner } from "./AlertBanner" import Button from "@material-ui/core/Button" import { makeMockApiError } from "testHelpers/entities" import { AlertBannerProps } from "./alertTypes" From 476019b0410a0a747cac67974c7737bf71b79312 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 19 Oct 2022 18:31:59 +0000 Subject: [PATCH 39/42] Try removing story --- site/src/components/AlertBanner/AlertBanner.stories.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx index 10eebd8ed810c..300cd75d84906 100644 --- a/site/src/components/AlertBanner/AlertBanner.stories.tsx +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -100,9 +100,3 @@ ErrorWithActionRetryAndDismiss.args = { dismissible: true, severity: "error", } - -export const ErrorAsWarning = Template.bind({}) -ErrorAsWarning.args = { - error: mockError, - severity: "warning", -} From 1f629742a460921da9c8fb909bcc7f98468b5cc8 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Thu, 20 Oct 2022 16:07:57 +0000 Subject: [PATCH 40/42] Revert "Fix story" This reverts commit c06765b7fb859f95972c99880f18ca0739295850. --- .../WorkspacesPageView.stories.tsx | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 9162e9de806e0..f25607dda1f0c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -21,14 +21,11 @@ const createWorkspaceItemRef = ( transition: WorkspaceTransition = "start", outdated = false, lastUsedAt = "0001-01-01", - id?: string, ): WorkspaceItemMachineRef => { - const wsId = id ?? MockWorkspace.id return spawn( workspaceItemMachine.withContext({ data: { ...MockWorkspace, - id: wsId, outdated, latest_build: { ...MockWorkspace.latest_build, @@ -86,18 +83,6 @@ const additionalWorkspaces: Record = { ), } -const fillerWorkspaces = Array(14) - .fill(undefined) - .map((_, i) => - createWorkspaceItemRef( - "running", - undefined, - true, - dayjs().toString(), - `test-workspace-${i}`, - ), - ) - export default { title: "pages/WorkspacesPageView", component: WorkspacesPageView, @@ -109,18 +94,6 @@ export default { ], mapping: { ...workspaces, ...additionalWorkspaces }, }, - onFilter: { - action: "filter", - }, - onGoToPage: { - action: "go to page", - }, - onNext: { - action: "next", - }, - onPrevious: { - action: "previous", - }, }, } as ComponentMeta @@ -134,29 +107,16 @@ AllStates.args = { ...Object.values(workspaces), ...Object.values(additionalWorkspaces), ], - count: 14, } export const OwnerHasNoWorkspaces = Template.bind({}) OwnerHasNoWorkspaces.args = { workspaceRefs: [], - count: 0, filter: workspaceFilterQuery.me, } export const NoResults = Template.bind({}) NoResults.args = { workspaceRefs: [], - count: 0, filter: "searchtearmwithnoresults", } - -export const TwoPages = Template.bind({}) -TwoPages.args = { - workspaceRefs: [ - ...Object.values(workspaces), - ...Object.values(additionalWorkspaces), - ...fillerWorkspaces, - ], - count: 28, -} From e182c19ab3ea6e963ebcc325b742cd8fcea1ac28 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Thu, 20 Oct 2022 16:24:39 +0000 Subject: [PATCH 41/42] Add counts to page view story --- site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index f25607dda1f0c..0fd04c3af789f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -107,16 +107,19 @@ AllStates.args = { ...Object.values(workspaces), ...Object.values(additionalWorkspaces), ], + count: 14, } export const OwnerHasNoWorkspaces = Template.bind({}) OwnerHasNoWorkspaces.args = { workspaceRefs: [], filter: workspaceFilterQuery.me, + count: 0, } export const NoResults = Template.bind({}) NoResults.args = { workspaceRefs: [], filter: "searchtearmwithnoresults", + count: 0, } From 8bd9afa723725477b7da8e47c2cb02166514922e Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Thu, 20 Oct 2022 16:32:49 +0000 Subject: [PATCH 42/42] Revert "Try removing story" This reverts commit 476019b0410a0a747cac67974c7737bf71b79312. --- site/src/components/AlertBanner/AlertBanner.stories.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx index 300cd75d84906..10eebd8ed810c 100644 --- a/site/src/components/AlertBanner/AlertBanner.stories.tsx +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -100,3 +100,9 @@ ErrorWithActionRetryAndDismiss.args = { dismissible: true, severity: "error", } + +export const ErrorAsWarning = Template.bind({}) +ErrorAsWarning.args = { + error: mockError, + severity: "warning", +}