diff --git a/dashboard/package.json b/dashboard/package.json index 2cc005e8aa..91c7e9d868 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -45,9 +45,9 @@ "uuid": "^3.3.2" }, "scripts": { - "start": "CI=true react-scripts start", + "start": "react-scripts start", "build": "react-scripts build && ./bin/copy-to-static", - "dev": "EXTEND_ESLINT=true npm run start", + "dev": "EXTEND_ESLINT=true CI=true npm run start", "test-watch": "react-scripts test", "test": "CI=true react-scripts test", "eject": "react-scripts eject" diff --git a/dashboard/src/api/actions.ts b/dashboard/src/api/actions.ts new file mode 100644 index 0000000000..b1b7bd429f --- /dev/null +++ b/dashboard/src/api/actions.ts @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import produce from "immer" +import { groupBy } from "lodash" + +import { ServiceLogEntry } from "garden-service/build/src/types/plugin/service/getServiceLogs" +import { StatusCommandResult } from "garden-service/build/src/commands/get/get-status" +import { TaskResultOutput } from "garden-service/build/src/commands/get/get-task-result" +import { ConfigDump } from "garden-service/build/src/garden" +import { TestResultOutput } from "garden-service/build/src/commands/get/get-test-result" +import { GraphOutput } from "garden-service/build/src/commands/get/get-graph" +import { + Entities, + Module, + Service, + Task, + Test, + ApiDispatch, +} from "../contexts/api" +import { + fetchLogs, + fetchStatus, + fetchTaskResult, + fetchConfig, + fetchTestResult, + fetchGraph, + FetchTaskResultParams, + FetchTestResultParams, +} from "./api" + +/** + * This file contains the API action functions. + * + * The actions are responsible for dispatching the appropriate action types and normalising the + * API response. + */ + +export async function loadConfig(dispatch: ApiDispatch) { + const requestKey = "config" + + dispatch({ requestKey, type: "fetchStart" }) + let res: ConfigDump + + try { + res = await fetchConfig() + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + const processResults = (entities: Entities) => processConfig(entities, res) + + dispatch({ type: "fetchSuccess", requestKey, processResults }) +} + +function processConfig(entities: Entities, config: ConfigDump) { + let modules: { [moduleName: string]: Module } = {} + let services: { [serviceName: string]: Service } = {} + let tasks: { [taskName: string]: Task } = {} + let tests: { [testKey: string]: Test } = {} + + for (const cfg of config.moduleConfigs) { + + const module: Module = { + name: cfg.name, + type: cfg.type, + path: cfg.path, + repositoryUrl: cfg.repositoryUrl, + description: cfg.description, + services: cfg.serviceConfigs.map(service => service.name), + tests: cfg.testConfigs.map(test => `${cfg.name}.${test.name}`), + tasks: cfg.taskConfigs.map(task => task.name), + taskState: "taskComplete", + } + modules[cfg.name] = module + for (const serviceConfig of cfg.serviceConfigs) { + services[serviceConfig.name] = { + ...services[serviceConfig.name], + config: serviceConfig, + } + } + for (const testConfig of cfg.testConfigs) { + const testKey = `${cfg.name}.${testConfig.name}` + tests[testKey] = { + ...tests[testKey], + config: testConfig, + } + } + for (const taskConfig of cfg.taskConfigs) { + tasks[taskConfig.name] = { + ...tasks[taskConfig.name], + config: taskConfig, + } + } + } + + return produce(entities, draft => { + draft.modules = modules + draft.services = services + draft.tests = tests + draft.tasks = tasks + draft.project.root = config.projectRoot + }) +} + +export async function loadLogs(dispatch: ApiDispatch, serviceNames: string[]) { + const requestKey = "logs" + + dispatch({ requestKey, type: "fetchStart" }) + + let res: ServiceLogEntry[] + try { + res = await fetchLogs({ serviceNames }) + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + const processResults = (entities: Entities) => processLogs(entities, res) + + dispatch({ type: "fetchSuccess", requestKey, processResults }) +} + +function processLogs(entities: Entities, logs: ServiceLogEntry[]) { + return produce(entities, draft => { + draft.logs = groupBy(logs, "serviceName") + }) +} + +export async function loadStatus(dispatch: ApiDispatch) { + const requestKey = "status" + + dispatch({ requestKey, type: "fetchStart" }) + + let res: StatusCommandResult + try { + res = await fetchStatus() + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + const processResults = (entities: Entities) => processStatus(entities, res) + + dispatch({ type: "fetchSuccess", requestKey, processResults }) +} + +function processStatus(entities: Entities, status: StatusCommandResult) { + return produce(entities, draft => { + for (const serviceName of Object.keys(status.services)) { + draft.services[serviceName] = { + ...draft.services[serviceName], + status: status.services[serviceName], + } + } + for (const testName of Object.keys(status.tests)) { + draft.tests[testName] = { + ...draft.tests[testName], + status: status.tests[testName], + } + } + for (const taskName of Object.keys(status.tasks)) { + draft.tasks[taskName] = { + ...draft.tasks[taskName], + status: status.tasks[taskName], + } + } + draft.providers = status.providers + }) +} + +interface LoadTaskResultParams extends FetchTaskResultParams { + dispatch: ApiDispatch +} + +export async function loadTaskResult({ dispatch, ...fetchParams }: LoadTaskResultParams) { + const requestKey = "taskResult" + + dispatch({ requestKey, type: "fetchStart" }) + + let res: TaskResultOutput + try { + res = await fetchTaskResult(fetchParams) + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + const processResults = (entities: Entities) => processTaskResult(entities, res) + + dispatch({ type: "fetchSuccess", requestKey, processResults }) +} + +function processTaskResult(entities: Entities, result: TaskResultOutput) { + return produce(entities, draft => { + draft.tasks = draft.tasks || {} + draft.tasks[result.name] = draft.tasks[result.name] || {} + draft.tasks[result.name].result = result + }) +} + +interface LoadTestResultParams extends FetchTestResultParams { + dispatch: ApiDispatch +} + +export async function loadTestResult({ dispatch, ...fetchParams }: LoadTestResultParams) { + const requestKey = "testResult" + + dispatch({ requestKey, type: "fetchStart" }) + + let res: TestResultOutput + try { + res = await fetchTestResult(fetchParams) + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + const processResults = (entities: Entities) => processTestResult(entities, res) + + dispatch({ type: "fetchSuccess", requestKey, processResults }) +} + +function processTestResult(entities: Entities, result: TestResultOutput) { + return produce(entities, draft => { + draft.tests = draft.tests || {} + draft.tests[result.name] = draft.tests[result.name] || {} + draft.tests[result.name].result = result + }) +} + +export async function loadGraph(dispatch: ApiDispatch) { + const requestKey = "graph" + + dispatch({ requestKey, type: "fetchStart" }) + + let res: GraphOutput + try { + res = await fetchGraph() + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + const processResults = (entities: Entities) => processGraph(entities, res) + + dispatch({ type: "fetchSuccess", requestKey, processResults }) +} + +function processGraph(entities: Entities, graph: GraphOutput) { + return produce(entities, draft => { + draft.graph = graph + }) +} diff --git a/dashboard/src/contexts/ws-handlers.ts b/dashboard/src/api/ws.ts similarity index 69% rename from dashboard/src/contexts/ws-handlers.ts rename to dashboard/src/api/ws.ts index 558a56f788..7fe24da764 100644 --- a/dashboard/src/contexts/ws-handlers.ts +++ b/dashboard/src/api/ws.ts @@ -10,12 +10,12 @@ import { ServerWebsocketMessage } from "garden-service/build/src/server/server" import { Events } from "garden-service/build/src/events" import { - Store, + Entities, Action, SupportedEventName, supportedEventNames, -} from "./api" -import getApiUrl from "../api/get-api-url" +} from "../contexts/api" +import getApiUrl from "./get-api-url" import produce from "immer" export type WsEventMessage = ServerWebsocketMessage & { @@ -47,8 +47,8 @@ export function initWebSocket(dispatch: React.Dispatch) { console.error(parsedMsg) } if (isSupportedEvent(parsedMsg)) { - const produceNextStore = (store: Store) => processWebSocketMessage(store, parsedMsg) - dispatch({ type: "wsMessageReceived", produceNextStore }) + const processResults = (store: Entities) => processWebSocketMessage(store, parsedMsg) + dispatch({ type: "wsMessageReceived", processResults }) } } return function cleanUp() { @@ -57,46 +57,47 @@ export function initWebSocket(dispatch: React.Dispatch) { } // Process the graph response and return a normalized store -function processWebSocketMessage(store: Store, message: WsEventMessage) { +function processWebSocketMessage(store: Entities, message: WsEventMessage) { const taskType = message.payload["type"] === "task" ? "run" : message.payload["type"] // convert "task" to "run" const taskState = message.name const entityName = message.payload["name"] - return produce(store, storeDraft => { + return produce(store, draft => { // We don't handle taskGraphComplete events if (taskType && taskState !== "taskGraphComplete") { - storeDraft.requestStates.fetchTaskStates.loading = true + draft.project.taskGraphProcessing = true switch (taskType) { case "publish": break case "deploy": - storeDraft.entities.services[entityName] = { - ...storeDraft.entities.services[entityName], + draft.services[entityName] = { + ...draft.services[entityName], taskState, } break case "build": - storeDraft.entities.modules[entityName] = { - ...store.entities.modules[entityName], + draft.modules[entityName] = { + ...store.modules[entityName], taskState, } break case "run": - storeDraft.entities.tasks[entityName] = { - ...store.entities.tasks[entityName], + draft.tasks[entityName] = { + ...store.tasks[entityName], taskState, } break case "test": - storeDraft.entities.tests[entityName] = { - ...store.entities.tests[entityName], + draft.tests[entityName] = { + ...store.tests[entityName], taskState, } break } } - if (taskState === "taskGraphComplete") { // add to requestState graph whenever its taskGraphComplete - storeDraft.requestStates.fetchTaskStates.loading = false + // add to requestState graph whenever its taskGraphComplete + if (taskState === "taskGraphComplete") { + draft.project.taskGraphProcessing = false } }) } diff --git a/dashboard/src/components/logs.tsx b/dashboard/src/components/logs.tsx index 0f3ef47d95..ae6207fa59 100644 --- a/dashboard/src/components/logs.tsx +++ b/dashboard/src/components/logs.tsx @@ -16,14 +16,13 @@ import Select from "react-select" import Terminal from "./terminal" import Card, { CardTitle } from "./card" import { colors } from "../styles/variables" -import { LoadLogs } from "../contexts/api" import { ServiceLogEntry } from "garden-service/build/src/types/plugin/service/getServiceLogs" import { ActionIcon } from "./action-icon" interface Props { logs: { [serviceName: string]: ServiceLogEntry[] } - onRefresh: LoadLogs + onRefresh: (serviceNames: string[]) => void } interface State { @@ -56,6 +55,7 @@ const selectStyles = { }), } +// TODO: Use functional component class Logs extends Component { constructor(props) { @@ -85,7 +85,7 @@ class Logs extends Component { if (!serviceNames.length) { return } - this.props.onRefresh({ serviceNames, force: true }) + this.props.onRefresh(serviceNames) this.setState({ loading: true }) } diff --git a/dashboard/src/containers/entity-result.tsx b/dashboard/src/containers/entity-result.tsx index 19c9ce3b27..b9b845e039 100644 --- a/dashboard/src/containers/entity-result.tsx +++ b/dashboard/src/containers/entity-result.tsx @@ -14,6 +14,7 @@ import { TaskResultOutput } from "garden-service/build/src/commands/get/get-task import { TestResultOutput } from "garden-service/build/src/commands/get/get-test-result" import { ErrorNotification } from "../components/notifications" import { EntityResultSupportedTypes } from "../contexts/ui" +import { loadTestResult, loadTaskResult } from "../api/actions" const ErrorMsg = ({ error, type }) => ( @@ -47,15 +48,18 @@ interface Props { */ export default ({ name, moduleName, type, onClose }: Props) => { const { - actions, - store: { entities: { tasks, tests }, requestStates: { fetchTestResult, fetchTaskResult } }, + dispatch, + store: { + entities: { tasks, tests }, + requestStates, + }, } = useApi() const loadResults = () => { if (type === "test") { - actions.loadTestResult({ name, moduleName, force: true }) + loadTestResult({ dispatch, name, moduleName }) } else if (type === "run" || type === "task") { - actions.loadTaskResult({ name, force: true }) + loadTaskResult({ name, dispatch }) } } @@ -64,38 +68,38 @@ export default ({ name, moduleName, type, onClose }: Props) => { if (type === "test") { const testResult = tests && tests[name] && tests[name].result - if (fetchTestResult.error) { - return + if (requestStates.testResult.error) { + return } return ( ) } else if (type === "task" || type === "run") { const taskResult = tasks && tasks[name] && tasks[name].result - if (fetchTaskResult.error) { - return + if (requestStates.taskResult.error) { + return } return ( ) diff --git a/dashboard/src/containers/graph.tsx b/dashboard/src/containers/graph.tsx index dd3b6d4d6e..e302933f08 100644 --- a/dashboard/src/containers/graph.tsx +++ b/dashboard/src/containers/graph.tsx @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React from "react" +import React, { useEffect } from "react" import styled from "@emotion/styled" import Graph from "../components/graph" import PageError from "../components/page-error" @@ -22,7 +22,8 @@ import { Filters } from "../components/group-filter" import { capitalize } from "lodash" import { RenderedNode } from "garden-service/build/src/config-graph" import { GraphOutput } from "garden-service/build/src/commands/get/get-graph" -import { useMountEffect } from "../util/helpers" +import { loadGraph } from "../api/actions" +import { useConfig } from "../util/hooks" const Wrapper = styled.div` padding-left: .75rem; @@ -37,8 +38,11 @@ export interface GraphOutputWithNodeStatus extends GraphOutput { export default () => { const { - actions, - store: { entities: { modules, services, tests, tasks, graph }, requestStates: { fetchGraph, fetchTaskStates } }, + dispatch, + store: { + entities: { project, modules, services, tests, tasks, graph }, + requestStates, + }, } = useApi() const { @@ -46,25 +50,21 @@ export default () => { state: { selectedGraphNode, isSidebarOpen, stackGraph: { filters } }, } = useUiState() - useMountEffect(() => { - async function fetchData() { - return await actions.loadConfig() - } - fetchData() - }) + useConfig(dispatch, requestStates.config) - useMountEffect(() => { - async function fetchData() { - return await actions.loadGraph() + useEffect(() => { + const fetchData = async () => loadGraph(dispatch) + + if (!(requestStates.graph.initLoadComplete || requestStates.graph.pending)) { + fetchData() } - fetchData() - }) + }, [dispatch, requestStates.graph]) - if (fetchGraph.error) { - return + if (requestStates.graph.error) { + return } - if (!fetchGraph.initLoadComplete) { + if (!requestStates.graph.initLoadComplete) { return } @@ -128,7 +128,7 @@ export default () => { graph={graphWithStatus} filters={graphFilters} onFilter={stackGraphToggleItemsView} - isProcessing={fetchTaskStates.loading} + isProcessing={project.taskGraphProcessing} /> {moreInfoPane} diff --git a/dashboard/src/containers/logs.tsx b/dashboard/src/containers/logs.tsx index 606d8f365c..7e9cd07711 100644 --- a/dashboard/src/containers/logs.tsx +++ b/dashboard/src/containers/logs.tsx @@ -6,50 +6,51 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useEffect } from "react" +import React, { useEffect, useCallback } from "react" import PageError from "../components/page-error" import Logs from "../components/logs" import { useApi } from "../contexts/api" import Spinner from "../components/spinner" -import { useMountEffect } from "../util/helpers" +import { loadLogs } from "../api/actions" +import { useConfig } from "../util/hooks" export default () => { const { - actions: { loadConfig, loadLogs }, - store: { entities: { logs, services }, requestStates: { fetchLogs, fetchConfig } }, + dispatch, + store: { + entities: { logs, services }, + requestStates, + }, } = useApi() const serviceNames: string[] = Object.keys(services) - useMountEffect(() => { - async function fetchData() { - return await loadConfig() - } - fetchData() - }) + useConfig(dispatch, requestStates.config) useEffect(() => { - async function fetchData() { - return await loadLogs({ serviceNames }) - } + const fetchData = async () => loadLogs(dispatch, serviceNames) - if (serviceNames.length) { + if (!(requestStates.logs.initLoadComplete || requestStates.logs.pending) && serviceNames.length) { fetchData() } - }, [serviceNames, loadLogs]) // run again only after config had been fetched + }, [dispatch, requestStates.logs, serviceNames]) - if (!(fetchConfig.initLoadComplete && fetchLogs.initLoadComplete)) { + if (!(requestStates.config.initLoadComplete && requestStates.logs.initLoadComplete)) { return } - if (fetchConfig.error || fetchLogs.error) { + if (requestStates.config.error || requestStates.logs.error) { return ( - + ) } + const handleRefresh = useCallback((names: string[]) => { + loadLogs(dispatch, names) + }, [dispatch]) + return ( - + ) } diff --git a/dashboard/src/containers/overview.tsx b/dashboard/src/containers/overview.tsx index 2712f79a54..7fe819310e 100644 --- a/dashboard/src/containers/overview.tsx +++ b/dashboard/src/containers/overview.tsx @@ -25,7 +25,7 @@ import { } from "../contexts/api" import Spinner from "../components/spinner" import { useUiState } from "../contexts/ui" -import { useMountEffect } from "../util/helpers" +import { useConfig } from "../util/hooks" const Overview = styled.div` padding-top: .5rem; @@ -78,11 +78,10 @@ const mapTasks = (taskEntities: Task[], moduleName: string): ModuleProps["taskCa export default () => { const { - actions, + dispatch, store: { - projectRoot, - entities: { modules, services, tests, tasks }, - requestStates: { fetchConfig, fetchStatus }, + entities: { project, modules, services, tests, tasks }, + requestStates, }, } = useApi() @@ -95,25 +94,20 @@ export default () => { }, } = useUiState() - useMountEffect(() => { - async function fetchData() { - return await actions.loadConfig() - } - fetchData() - }) + useConfig(dispatch, requestStates.config) const clearSelectedEntity = () => { selectEntity(null) } - if (fetchConfig.error || fetchStatus.error) { - return + if (requestStates.config.error || requestStates.status.error) { + return } // Note that we don't call the loadStatus function here since the Sidebar ensures that the status is always loaded. // FIXME: We should be able to call loadStatus safely and have the handler check if the status // has already been fetched or is pending. - if (!(fetchConfig.initLoadComplete && fetchStatus.initLoadComplete)) { + if (!(requestStates.config.initLoadComplete && requestStates.status.initLoadComplete)) { return } @@ -125,13 +119,13 @@ export default () => { return { name: module.name, type: module.type, - path: projectRoot.split("/").pop() + module.path.replace(projectRoot, ""), + path: project.root.split("/").pop() + module.path.replace(project.root, ""), repositoryUrl: module.repositoryUrl, description: module.description, serviceCardProps: mapServices(serviceEntities), testCardProps: mapTests(testEntities, module.name), taskCardProps: mapTasks(taskEntities, module.name), - isLoading: fetchStatus.loading, + isLoading: requestStates.status.pending, } }) diff --git a/dashboard/src/containers/sidebar.tsx b/dashboard/src/containers/sidebar.tsx index 629ae9beba..04d1e7157c 100644 --- a/dashboard/src/containers/sidebar.tsx +++ b/dashboard/src/containers/sidebar.tsx @@ -7,12 +7,12 @@ */ import { kebabCase, flatten, entries } from "lodash" -import React from "react" +import React, { useEffect } from "react" import Sidebar from "../components/sidebar" import { useApi } from "../contexts/api" import { DashboardPage } from "garden-service/build/src/config/status" -import { useMountEffect } from "../util/helpers" +import { loadStatus } from "../api/actions" export interface Page extends DashboardPage { path: string @@ -44,16 +44,20 @@ const builtinPages: Page[] = [ const SidebarContainer = () => { const { - actions, - store: { entities: { providers } }, + dispatch, + store: { + entities: { providers }, + requestStates, + }, } = useApi() - useMountEffect(() => { - async function fetchData() { - return await actions.loadStatus() + useEffect(() => { + const fetchData = async () => loadStatus(dispatch) + + if (!(requestStates.status.initLoadComplete || requestStates.status.pending)) { + fetchData() } - fetchData() - }) + }, [dispatch, requestStates.status]) let pages: Page[] = [] diff --git a/dashboard/src/contexts/README.md b/dashboard/src/contexts/README.md index c77e25f19d..25cf27e84a 100644 --- a/dashboard/src/contexts/README.md +++ b/dashboard/src/contexts/README.md @@ -2,26 +2,20 @@ This directory contains the React Contexts (and accompanying hooks) that are used by the Dashboard. -The Contexts use hooks to create actions and manage state. The actions and state are attached to a Provider via the `value` prop. The Providers are then added to the component tree, usually at the top. +The Contexts use hooks to manage state. The state and dispatch function are attached to a Provider via the `value` prop. The Providers are then added to the component tree, usually at the top. -This way, components down the tree can access the state and the actions that the Context-Provider pair manages. +This way, components down the tree can access the state and the dispatch function that the Context-Provider pair manages. ### api.tsx Here we define the global data store which is a normalized version of the data types used in the `garden-service` backend. -We use a `useReduce` kind of hook to create a store and a dispatch function. We also have a specific hook for creating the actions that call the API. - -These actions call a dedicated handler which checks if the data requested exists in the store and fetches it if needed. These handlers are also responsible for normalizing the data to fit the store shape. +We use a `useReduce` kind of hook to create a store and a dispatch function. The dispatch function gets passed to the API action functions in `api/actions.tsx`. The actions fetch the data and are also responsible for merging it correctly into the global store. Here, we also initialize the websocket connection. Data received via websocket events is also merged into to the global store object if applicable, so that we can automatically re-render any affected components. -Finally, the actions and the store are added to the API Provider so that they are accessible throughout the component tree. +Finally, the dispatch and the store are added to the API Provider so that they are accessible throughout the component tree. ### ui.tsx The UI Context manages the global UI state. It should only contain UI state that truly needs to be global. All other UI state can be managed at the component level. - -### api-handlers.tsx - -The handler functions are responsible for checking whether data exists in the store, fetching it if doesn't, and normalizing the response so that it can be easily merged into the store. diff --git a/dashboard/src/contexts/api-handlers.ts b/dashboard/src/contexts/api-handlers.ts deleted file mode 100644 index 8e01ea2386..0000000000 --- a/dashboard/src/contexts/api-handlers.ts +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright (C) 2018 Garden Technologies, Inc. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import produce from "immer" -import { groupBy } from "lodash" - -import { ServiceLogEntry } from "garden-service/build/src/types/plugin/service/getServiceLogs" -import { StatusCommandResult } from "garden-service/build/src/commands/get/get-status" -import { TaskResultOutput } from "garden-service/build/src/commands/get/get-task-result" -import { ConfigDump } from "garden-service/build/src/garden" -import { TestResultOutput } from "garden-service/build/src/commands/get/get-test-result" -import { GraphOutput } from "garden-service/build/src/commands/get/get-graph" -import { - Store, - Action, - Module, - Service, - Task, - Test, -} from "./api" -import { - fetchLogs, - fetchStatus, - fetchTaskResult, - fetchConfig, - fetchTestResult, - fetchGraph, - FetchLogsParams, - FetchTaskResultParams, - FetchTestResultParams, -} from "../api/api" - -/** - * This file contains handler functions that the API hook calls to load data. - * - * The handlers are responsible for dispatching the appropriate actions and normalising the - * API response. - * - * The handlers return without fetching if the data already exists in the store (and force is set to false) - */ - -interface LoadHandlerParams { - store: Store, - dispatch: React.Dispatch, - force?: boolean, -} - -export async function loadConfigHandler({ store, dispatch, force = false }: LoadHandlerParams) { - const requestKey = "fetchConfig" - - if (!force && store.requestStates[requestKey].initLoadComplete) { - return - } - - dispatch({ requestKey, type: "fetchStart" }) - let res: ConfigDump - try { - res = await fetchConfig() - } catch (error) { - dispatch({ requestKey, type: "fetchFailure", error }) - return - } - - const produceNextStore = (currentStore: Store) => processConfig(currentStore, res) - - dispatch({ type: "fetchSuccess", requestKey, produceNextStore }) -} - -function processConfig(store: Store, config: ConfigDump) { - let modules: { [moduleName: string]: Module } = {} - let services: { [serviceName: string]: Service } = {} - let tasks: { [taskName: string]: Task } = {} - let tests: { [testKey: string]: Test } = {} - - for (const cfg of config.moduleConfigs) { - - const module: Module = { - name: cfg.name, - type: cfg.type, - path: cfg.path, - repositoryUrl: cfg.repositoryUrl, - description: cfg.description, - services: cfg.serviceConfigs.map(service => service.name), - tests: cfg.testConfigs.map(test => `${cfg.name}.${test.name}`), - tasks: cfg.taskConfigs.map(task => task.name), - taskState: "taskComplete", - } - modules[cfg.name] = module - for (const serviceConfig of cfg.serviceConfigs) { - services[serviceConfig.name] = { - ...services[serviceConfig.name], - config: serviceConfig, - } - } - for (const testConfig of cfg.testConfigs) { - const testKey = `${cfg.name}.${testConfig.name}` - tests[testKey] = { - ...tests[testKey], - config: testConfig, - } - } - for (const taskConfig of cfg.taskConfigs) { - tasks[taskConfig.name] = { - ...tasks[taskConfig.name], - config: taskConfig, - } - } - } - - return produce(store, storeDraft => { - storeDraft.entities.modules = modules - storeDraft.entities.services = services - storeDraft.entities.tests = tests - storeDraft.entities.tasks = tasks - storeDraft.projectRoot = config.projectRoot - }) -} - -interface LoadLogsHandlerParams extends LoadHandlerParams, FetchLogsParams { } - -export async function loadLogsHandler({ serviceNames, store, dispatch, force = false }: LoadLogsHandlerParams) { - const requestKey = "fetchLogs" - - if ((!force && store.requestStates[requestKey].initLoadComplete) || !serviceNames.length) { - return - } - dispatch({ requestKey, type: "fetchStart" }) - - let res: ServiceLogEntry[] - try { - res = await fetchLogs({ serviceNames }) - } catch (error) { - dispatch({ requestKey, type: "fetchFailure", error }) - return - } - - const produceNextStore = (currentStore: Store) => processLogs(currentStore, res) - - dispatch({ type: "fetchSuccess", requestKey, produceNextStore }) -} - -function processLogs(store: Store, logs: ServiceLogEntry[]) { - return produce(store, storeDraft => { - storeDraft.entities.logs = groupBy(logs, "serviceName") - }) -} - -export async function loadStatusHandler({ store, dispatch, force = false }: LoadHandlerParams) { - const requestKey = "fetchStatus" - - if (!force && store.requestStates[requestKey].initLoadComplete) { - return - } - - dispatch({ requestKey, type: "fetchStart" }) - - let res: StatusCommandResult - try { - res = await fetchStatus() - } catch (error) { - dispatch({ requestKey, type: "fetchFailure", error }) - return - } - - const produceNextStore = (currentStore: Store) => processStatus(currentStore, res) - - dispatch({ type: "fetchSuccess", requestKey, produceNextStore }) -} - -function processStatus(store: Store, status: StatusCommandResult) { - return produce(store, storeDraft => { - for (const serviceName of Object.keys(status.services)) { - storeDraft.entities.services[serviceName] = { - ...storeDraft.entities.services[serviceName], - status: status.services[serviceName], - } - } - for (const testName of Object.keys(status.tests)) { - storeDraft.entities.tests[testName] = { - ...storeDraft.entities.tests[testName], - status: status.tests[testName], - } - } - for (const taskName of Object.keys(status.tasks)) { - storeDraft.entities.tasks[taskName] = { - ...storeDraft.entities.tasks[taskName], - status: status.tasks[taskName], - } - } - storeDraft.entities.providers = status.providers - }) -} - -interface LoadTaskResultHandlerParams extends LoadHandlerParams, FetchTaskResultParams { } - -export async function loadTaskResultHandler( - { store, dispatch, force = false, ...fetchParams }: LoadTaskResultHandlerParams, -) { - const requestKey = "fetchTaskResult" - - if (!force && store.requestStates[requestKey].initLoadComplete) { - return - } - - dispatch({ requestKey, type: "fetchStart" }) - - let res: TaskResultOutput - try { - res = await fetchTaskResult(fetchParams) - } catch (error) { - dispatch({ requestKey, type: "fetchFailure", error }) - return - } - - const produceNextStore = (currentStore: Store) => processTaskResult(currentStore, res) - - dispatch({ type: "fetchSuccess", requestKey, produceNextStore }) -} - -function processTaskResult(store: Store, result: TaskResultOutput) { - return produce(store, storeDraft => { - storeDraft.entities.tasks = storeDraft.entities.tasks || {} - storeDraft.entities.tasks[result.name] = storeDraft.entities.tasks[result.name] || {} - storeDraft.entities.tasks[result.name].result = result - }) -} - -interface LoadTestResultParams extends LoadHandlerParams, FetchTestResultParams { } - -export async function loadTestResultHandler({ store, dispatch, force = false, ...fetchParams }: LoadTestResultParams) { - const requestKey = "fetchTestResult" - - if (!force && store.requestStates[requestKey].initLoadComplete) { - return - } - - dispatch({ requestKey, type: "fetchStart" }) - - let res: TestResultOutput - try { - res = await fetchTestResult(fetchParams) - } catch (error) { - dispatch({ requestKey, type: "fetchFailure", error }) - return - } - - const produceNextStore = (currentStore: Store) => processTestResult(currentStore, res) - - dispatch({ type: "fetchSuccess", requestKey, produceNextStore }) -} - -function processTestResult(store: Store, result: TestResultOutput) { - return produce(store, storeDraft => { - storeDraft.entities.tests = storeDraft.entities.tests || {} - storeDraft.entities.tests[result.name] = storeDraft.entities.tests[result.name] || {} - storeDraft.entities.tests[result.name].result = result - }) -} - -export async function loadGraphHandler({ store, dispatch, force = false }: LoadHandlerParams) { - const requestKey = "fetchGraph" - - if (!force && store.requestStates[requestKey].initLoadComplete) { - return - } - - dispatch({ requestKey, type: "fetchStart" }) - - let res: GraphOutput - try { - res = await fetchGraph() - } catch (error) { - dispatch({ requestKey, type: "fetchFailure", error }) - return - } - - const produceNextStore = (currentStore: Store) => processGraph(currentStore, res) - - dispatch({ type: "fetchSuccess", requestKey, produceNextStore }) -} - -function processGraph(store: Store, graph: GraphOutput) { - return produce(store, storeDraft => { - storeDraft.entities.graph = graph - }) -} diff --git a/dashboard/src/contexts/api.tsx b/dashboard/src/contexts/api.tsx index c479a77117..dc50369d35 100644 --- a/dashboard/src/contexts/api.tsx +++ b/dashboard/src/contexts/api.tsx @@ -24,20 +24,7 @@ import { TestResultOutput } from "garden-service/build/src/commands/get/get-test import { TestConfig } from "garden-service/build/src/config/test" import { EventName } from "garden-service/build/src/events" import { EnvironmentStatusMap } from "garden-service/build/src/types/plugin/provider/getEnvironmentStatus" -import { - loadLogsHandler, - loadStatusHandler, - loadTaskResultHandler, - loadConfigHandler, - loadTestResultHandler, - loadGraphHandler, -} from "./api-handlers" -import { - FetchLogsParams, - FetchTaskResultParams, - FetchTestResultParams, -} from "../api/api" -import { initWebSocket } from "./ws-handlers" +import { initWebSocket } from "../api/ws" export type SupportedEventName = PickFromUnion Store +type ProcessResults = (entities: Entities) => Entities interface ActionBase { type: "fetchStart" | "fetchSuccess" | "fetchFailure" | "wsMessageReceived" @@ -154,7 +144,7 @@ interface ActionStart extends ActionBase { interface ActionSuccess extends ActionBase { requestKey: RequestKey type: "fetchSuccess" - produceNextStore: ProduceNextStore + processResults: ProcessResults } interface ActionError extends ActionBase { @@ -165,42 +155,22 @@ interface ActionError extends ActionBase { interface WsMessageReceived extends ActionBase { type: "wsMessageReceived" - produceNextStore: ProduceNextStore + processResults: ProcessResults } export type Action = ActionStart | ActionError | ActionSuccess | WsMessageReceived -interface LoadActionParams { - force?: boolean -} -type LoadAction = (param?: LoadActionParams) => Promise - -interface LoadLogsParams extends LoadActionParams, FetchLogsParams { } -export type LoadLogs = (param: LoadLogsParams) => Promise - -interface LoadTaskResultParams extends LoadActionParams, FetchTaskResultParams { } -type LoadTaskResult = (param: LoadTaskResultParams) => Promise - -interface LoadTestResultParams extends LoadActionParams, FetchTestResultParams { } -type LoadTestResult = (param: LoadTestResultParams) => Promise - -interface Actions { - loadLogs: LoadLogs - loadTaskResult: LoadTaskResult - loadTestResult: LoadTestResult - loadConfig: LoadAction - loadStatus: LoadAction - loadGraph: LoadAction -} - const initialRequestState = requestKeys.reduce((acc, key) => { - acc[key] = { loading: false, initLoadComplete: false } + acc[key] = { pending: false, initLoadComplete: false } return acc }, {} as { [K in RequestKey]: RequestState }) const initialState: Store = { - projectRoot: "", entities: { + project: { + root: "", + taskGraphProcessing: false, + }, modules: {}, services: {}, tasks: {}, @@ -215,57 +185,33 @@ const initialState: Store = { /** * The reducer for the useApiProvider hook. Sets the state for a given slice of the store on fetch events. */ -function reducer(store: Store, action: Action): Store { - let nextStore: Store = store - +const reducer = (store: Store, action: Action) => produce(store, draft => { switch (action.type) { case "fetchStart": - nextStore = produce(store, storeDraft => { - storeDraft.requestStates[action.requestKey].loading = true - }) + draft.requestStates[action.requestKey].pending = true break case "fetchSuccess": // Produce the next store state from the fetch result and update the request state - nextStore = produce(action.produceNextStore(store), storeDraft => { - storeDraft.requestStates[action.requestKey].loading = false - storeDraft.requestStates[action.requestKey].initLoadComplete = true - }) + draft.entities = action.processResults(store.entities) + draft.requestStates[action.requestKey].pending = false + draft.requestStates[action.requestKey].initLoadComplete = true break case "fetchFailure": - nextStore = produce(store, storeDraft => { - storeDraft.requestStates[action.requestKey].loading = false - storeDraft.requestStates[action.requestKey].error = action.error - // set didFetch to true on failure so the user can choose to force load the status - storeDraft.requestStates[action.requestKey].initLoadComplete = true - }) + draft.requestStates[action.requestKey].pending = false + draft.requestStates[action.requestKey].error = action.error + draft.requestStates[action.requestKey].initLoadComplete = true break case "wsMessageReceived": - nextStore = action.produceNextStore(store) + draft.entities = action.processResults(store.entities) break } +}) - return nextStore -} - -/** - * Hook that returns the store and the load actions that are passed down via the ApiProvider. - */ -function useApiActions(store: Store, dispatch: React.Dispatch) { - const actions: Actions = { - loadConfig: async (params: LoadActionParams = {}) => loadConfigHandler({ store, dispatch, ...params }), - loadStatus: async (params: LoadActionParams = {}) => loadStatusHandler({ store, dispatch, ...params }), - loadLogs: async (params: LoadLogsParams) => loadLogsHandler({ store, dispatch, ...params }), - loadTaskResult: async (params: LoadTaskResultParams) => loadTaskResultHandler({ store, dispatch, ...params }), - loadTestResult: async (params: LoadTestResultParams) => loadTestResultHandler({ store, dispatch, ...params }), - loadGraph: async (params: LoadActionParams = {}) => loadGraphHandler({ store, dispatch, ...params }), - } - - return actions -} +export type ApiDispatch = React.Dispatch type Context = { - store: Store; - actions: Actions; + store: Store + dispatch: ApiDispatch, } // Type cast the initial value to avoid having to check whether the context exists in every context consumer. @@ -283,7 +229,6 @@ export const useApi = () => useContext(Context) */ export const ApiProvider: React.FC = ({ children }) => { const [store, dispatch] = useReducer(reducer, initialState) - const actions = useApiActions(store, dispatch) // Set up the ws connection // TODO: Add websocket state as dependency (second argument) so that the websocket is re-initialised @@ -293,7 +238,7 @@ export const ApiProvider: React.FC = ({ children }) => { }, []) return ( - + {children} ) diff --git a/dashboard/src/util/helpers.ts b/dashboard/src/util/helpers.ts index 4ab8b99a26..a0a2c45936 100644 --- a/dashboard/src/util/helpers.ts +++ b/dashboard/src/util/helpers.ts @@ -8,7 +8,6 @@ import { flatten } from "lodash" import { ModuleConfig } from "garden-service/build/src/config/module" -import { useEffect } from "react" export function getServiceNames(moduleConfigs: ModuleConfig[]) { return flatten(moduleConfigs.map(m => m.serviceConfigs.map(s => s.name))) @@ -50,12 +49,3 @@ export const truncateMiddle = (str: string, resLength: number = 35) => { return str } - -/** - * For effects that should only run once on mount. Bypasses the react-hooks/exhaustive-deps lint warning. - * - * However, this pattern may not be desirable and the overall topic is widely debated. - * See e.g. here: https://github.com/facebook/react/issues/15865. - * Here's the suggested solution: https://github.com/facebook/create-react-app/issues/6880#issuecomment-488158024 - */ -export const useMountEffect = (fun: () => void) => useEffect(fun, []) diff --git a/dashboard/src/util/hooks.tsx b/dashboard/src/util/hooks.tsx new file mode 100644 index 0000000000..54762fec73 --- /dev/null +++ b/dashboard/src/util/hooks.tsx @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { ApiDispatch, RequestState } from "../contexts/api" +import { useEffect } from "react" +import { loadConfig } from "../api/actions" + +// This file contains common hooks that are used by multiple components. + +/** + * The hook for loading the config. + */ +export const useConfig = (dispatch: ApiDispatch, requestState: RequestState) => useEffect(() => { + const fetchData = async () => loadConfig(dispatch) + + if (!(requestState.initLoadComplete || requestState.pending)) { + fetchData() + } +}, [dispatch, requestState]) + +/** + * For effects that should only run once on mount. Bypasses the react-hooks/exhaustive-deps lint warning. + * + * However, this pattern may not be desirable and the overall topic is widely debated. + * See e.g. here: https://github.com/facebook/react/issues/15865. + * Here's the suggested solution: https://github.com/facebook/create-react-app/issues/6880#issuecomment-488158024 + */ +export const useMountEffect = (fun: () => void) => useEffect(fun, [])