diff --git a/dashboard/README.md b/dashboard/README.md index 64f10f3763..794be006a9 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -94,14 +94,12 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo ### Structure -The app is structured into presentational components (`src/components`), container components (`src/container`), and provider/consumer components (`src/context`). +The app is structured into presentational components (`src/components`), container components (`src/container`), and context components (`src/contexts`). **Presentational components:** These are re-usable UI components. They receive outside data as props and have minimal state. **Container components:** These load data and pass to the presentational components. A container might call the API directly or obtain the data from a consumer component (or both). -**Provider/consumer components:** These are re-usable components that contain "global" data that needs to be accessible by many (presentational) components in the tree. The provider/consumer pattern is a part of the new [React context API](https://reactjs.org/docs/context.html). +**Context components:** [A Context](https://reactjs.org/docs/context.html) contains "global" state that needs to be accessible by other components down the tree. The context components use the [React Hooks API](https://reactjs.org/docs/hooks-intro.html) to create actions and manage the state that gets passed down. Maintaining this separation will make it easier to migrate to different state management patterns/tools as the app evolves. - -We also use the new [React Hooks API](https://reactjs.org/docs/hooks-intro.html) to manage data and state. diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 0a90e1efcd..d9c280d9fd 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -9413,10 +9413,9 @@ "dev": true }, "immer": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz", - "integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==", - "dev": true + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-3.1.3.tgz", + "integrity": "sha512-HG5SXTXTTVy9lGNwS075cNhQoV375jHsIJO3UtMjuUWJOuwlMr0u42FlsKTJcppt5AzsFAsmj9r4kHvsSHh3hQ==" }, "import-cwd": { "version": "2.1.0", @@ -18350,6 +18349,12 @@ "locate-path": "^3.0.0" } }, + "immer": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz", + "integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==", + "dev": true + }, "inquirer": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.2.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 908c690ddd..cf7d301b27 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -31,6 +31,7 @@ "flexboxgrid-helpers": "^1.1.3", "garden-service": "^0.10.9", "http-proxy-middleware": "^0.19.1", + "immer": "^3.1.3", "lodash": "^4.17.11", "moment": "^2.24.0", "node-sass": "^4.10.0", @@ -43,7 +44,7 @@ "uuid": "^3.3.2" }, "scripts": { - "start": "react-scripts start", + "start": "CI=true react-scripts start", "build": "react-scripts build && ./bin/copy-to-static", "dev": "npm run start", "test-watch": "react-scripts test", diff --git a/dashboard/src/api/api.ts b/dashboard/src/api/api.ts index 23c0c803f9..f202a1ac57 100644 --- a/dashboard/src/api/api.ts +++ b/dashboard/src/api/api.ts @@ -14,15 +14,12 @@ import { TestResultOutput } from "garden-service/build/src/commands/get/get-test import { ServiceLogEntry } from "garden-service/build/src/types/plugin/service/getServiceLogs" import { CommandResult } from "garden-service/build/src/commands/base" import { ConfigDump } from "garden-service/build/src/garden" -import { AllEnvironmentStatus } from "garden-service/build/src/actions" +import { StatusCommandResult } from "garden-service/build/src/commands/get/get-status" export interface ApiRequest { command: string parameters: {} } -export type FetchLogsParam = string[] -export type FetchTaskResultParam = { name: string } -export type FetchTestResultParam = { name: string, module: string } const MAX_LOG_LINES = 5000 @@ -35,20 +32,33 @@ export async function fetchGraph() { } export async function fetchStatus() { - return apiPost("get.status", { output: "json" }) + return apiPost("get.status", { output: "json" }) } -export async function fetchLogs(services: FetchLogsParam) { - const tail = Math.floor(MAX_LOG_LINES / services.length) - return apiPost("logs", { services, tail }) +export interface FetchLogsParams { + serviceNames: string[] } -export async function fetchTaskResult(params: FetchTaskResultParam) { +export async function fetchLogs({ serviceNames }: FetchLogsParams) { + const tail = Math.floor(MAX_LOG_LINES / serviceNames.length) + return apiPost("logs", { services: serviceNames, tail }) +} + +export interface FetchTaskResultParams { + name: string +} + +export async function fetchTaskResult(params: FetchTaskResultParams) { return apiPost("get.task-result", params) } -export async function fetchTestResult(params: FetchTestResultParam) { - return apiPost("get.test-result", params) +export interface FetchTestResultParams { + name: string + moduleName: string +} + +export async function fetchTestResult({ name, moduleName }: FetchTestResultParams) { + return apiPost("get.test-result", { name, module: moduleName }) } async function apiPost(command: string, parameters: {} = {}): Promise { @@ -64,7 +74,7 @@ async function apiPost(command: string, parameters: {} = {}): Promise { } if (!res.data.result) { - throw new Error("result is empty") + throw new Error("Empty response from server") } return res.data.result diff --git a/dashboard/src/app.tsx b/dashboard/src/app.tsx index 6fd25cee3d..af1d91e8b4 100644 --- a/dashboard/src/app.tsx +++ b/dashboard/src/app.tsx @@ -7,7 +7,7 @@ */ import { css } from "emotion" -import React, { useContext } from "react" +import React from "react" import styled from "@emotion/styled" import { Route } from "react-router-dom" @@ -23,15 +23,14 @@ import "./styles/padding-margin-mixin.scss" import "./styles/custom-flexboxgrid.scss" import "./styles/icons.scss" -import { EventProvider } from "./context/events" -import { DataProvider } from "./context/data" import { NavLink } from "./components/links" import logo from "./assets/logo.png" import { ReactComponent as OpenSidebarIcon } from "./assets/open-pane.svg" import { ReactComponent as CloseSidebarIcon } from "./assets/close-pane.svg" -import { UiStateProvider, UiStateContext } from "./context/ui" +import { UiStateProvider, useUiState } from "./contexts/ui" +import { ApiProvider } from "./contexts/api" // Style and align properly const Logo = styled.img` @@ -67,13 +66,11 @@ const SidebarToggleButton = styled.div` const AppContainer = () => { return (
- - - - - - - + + + + +
) } @@ -82,7 +79,7 @@ const App = () => { const { state: { isSidebarOpen }, actions: { toggleSidebar }, - } = useContext(UiStateContext) + } = useUiState() return (
` +export const EntityCardWrap = styled.div` max-height: 13rem; - background-color: ${props => (props && props.type && colors.cardTypes[props.type] || "white")}; + background-color: white; margin-right: 1rem; box-shadow: 2px 2px 9px rgba(0,0,0,0.14); border-radius: 4px; @@ -34,13 +28,13 @@ const EntityCard = styled.div` } ` -const Header = styled.div` +export const Header = styled.div` width: 100%; display:flex; justify-content: space-between; ` -const Content = styled.div` +export const Content = styled.div` width: 100%; position: relative; max-height: 10rem; @@ -51,10 +45,11 @@ const Content = styled.div` } ` -type StateContainerProps = { +type StateLabelProps = { state: string, } -const StateContainer = styled.div` + +export const StateLabel = styled.div` padding: 0 .5rem; margin-left: auto; background-color: ${props => (props && props.state ? colors.state[props.state] : colors.gardenGrayLight)}; @@ -70,7 +65,7 @@ const StateContainer = styled.div` height: 1rem; ` -const Tag = styled.div` +export const Label = styled.div` display: flex; align-items: center; font-weight: 500; @@ -81,46 +76,62 @@ const Tag = styled.div` color: #90A0B7; ` -const Name = styled.div` +export const Name = styled.div` font-size: 0.9375rem; font-weight: 500; color: rgba(0, 0, 0, .87); padding-top: 0.125rem; ` -type EntityType = "service" | "test" | "task" +type FieldWrapProps = { + visible: boolean, +} + +export const FieldWrap = styled.div` + display: ${props => (props.visible ? `block` : "none")}; + animation: fadein .5s; + @keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +` -interface Props { - type: EntityType - children: ReactNode - entity: Entity +type FieldProps = { + inline?: boolean, + visible: boolean, } -export default ({ - children, - type, - entity: { name, isLoading, state }, -}: Props) => { - - return ( - -
-
- {type.toUpperCase()} - {name} -
- {state && ( - - {state} - - )} -
- - {isLoading && ( - - )} - {!isLoading && children} - -
- ) +export const Field = styled.div` + display: ${props => (props.visible ? (props.inline ? "flex" : "block") : "none")}; + flex-direction: row; +` + +type FieldGroupProps = { + visible: boolean, } + +export const FieldGroup = styled.div` + display: ${props => (props.visible ? "flex" : "none")}; + flex-direction: row; + padding-top: .25rem; +` + +export const Key = styled.div` + padding-right: .25rem; + font-size: 0.8125rem; + line-height: 1.1875rem; + letter-spacing: 0.01em; + color: #4C5862; + opacity: 0.5; +` + +export const Value = styled.div` + padding-right: .5rem; + font-size: 0.8125rem; + line-height: 1.1875rem; + letter-spacing: 0.01em; +` diff --git a/dashboard/src/components/entity-cards/module.tsx b/dashboard/src/components/entity-cards/module.tsx new file mode 100644 index 0000000000..48cc55693c --- /dev/null +++ b/dashboard/src/components/entity-cards/module.tsx @@ -0,0 +1,170 @@ +/* + * 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 React, { useState } from "react" +import styled from "@emotion/styled" + +import { Omit } from "garden-service/build/src/util/util" + +import { useUiState } from "../../contexts/ui" +import { TestCard, Props as TestCardProps } from "./test-card" +import { TaskCard, Props as TaskCardProps } from "./task" +import { ServiceCard, Props as ServiceCardProps } from "./service" +import { Module } from "../../contexts/api" +import { Field, Value, FieldWrap } from "./common" + +const Wrap = styled.div` + padding: 1.2rem; + background: white; + box-shadow: 0rem 0.375rem 1.125rem rgba(0, 0, 0, 0.06); + border-radius: 0.25rem; + margin: 0 1.3rem 1.3rem 0; + min-width: 17.5rem; + flex: 1 1; + max-width: 20rem; +` + +type CardWrapProps = { + visible: boolean, +} + +const CardWrap = styled.div` + padding-top: 1rem; + display: flex; + flex-wrap: wrap; + align-items: middle; + display: ${props => (props.visible ? `block` : "none")}; + animation: fadein .5s ; + + @keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +` + +const Header = styled.div` + line-height: 1rem; + display: flex; + align-items: baseline; + align-self: flex-start; + justify-content: space-between; +` + +const Name = styled.div` + font-weight: 500; + font-size: 0.9375rem; + letter-spacing: 0.01em; + color: #323C47; +` + +const Tag = styled.div` + padding-left: .5rem; + font-weight: 500; + font-size: 0.625rem; + letter-spacing: 0.01em; + color: #90A0B7; +` + +const Description = styled(Field)` + color: #4C5862; + opacity: 0.5; + padding-top: .25rem; +` + +const Full = styled(Value)` + cursor: pointer; +` + +const Short = styled(Value)` + cursor: pointer; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +` + +export type Props = Pick & { + serviceCardProps: Omit[] + testCardProps: Omit[] + taskCardProps: Omit[] + isLoading: boolean, +} + +export const ModuleCard = ({ + serviceCardProps = [], testCardProps = [], taskCardProps = [], name, type, description, + isLoading, +}: Props) => { + const { + state: { overview: { filters } }, + actions: { selectEntity }, + } = useUiState() + + const [isValueExpended, setValueExpendedState] = useState(false) + const toggleValueExpendedState = () => (setValueExpendedState(!isValueExpended)) + + return ( + +
+ {name} + {type && type.toUpperCase()} MODULE +
+ + + {!isValueExpended && ( + {description} + )} + {isValueExpended && ( + {description} + )} + + + 0}> + {serviceCardProps.map(props => ( + + ))} + + 0}> + {testCardProps.map(props => ( + + ))} + + 0}> + {taskCardProps.map(props => ( + + ))} + +
+ ) +} diff --git a/dashboard/src/components/entity-cards/service.tsx b/dashboard/src/components/entity-cards/service.tsx new file mode 100644 index 0000000000..9f200f089c --- /dev/null +++ b/dashboard/src/components/entity-cards/service.tsx @@ -0,0 +1,74 @@ +/* + * 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 React from "react" +import { Facebook as ContentLoader } from "react-content-loader" +import { FieldWrap, Field, Key, Value } from "./common" +import Ingresses from "../ingresses" +import { + EntityCardWrap, + Header, + Content, + Name, + StateLabel, + Label, +} from "./common" +import { Service } from "../../contexts/api" + +export type Props = Pick & Pick & { + isLoading: boolean, + showInfo: boolean, +} + +export const ServiceCard = ({ + name, + dependencies, + state, + ingresses, + isLoading, + showInfo, +}: Props) => { + + return ( + +
+
+ + {name} +
+ {state && ( + + {state} + + )} +
+ + {isLoading && ( + + )} + {!isLoading && ( + + 0}> + Depends on: + {dependencies.join(", ")} + + 0}> + + + + )} + +
+ ) +} diff --git a/dashboard/src/components/entity-cards/task.tsx b/dashboard/src/components/entity-cards/task.tsx new file mode 100644 index 0000000000..38888605a1 --- /dev/null +++ b/dashboard/src/components/entity-cards/task.tsx @@ -0,0 +1,118 @@ +/* + * 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 React from "react" +import moment from "moment" +import { Facebook as ContentLoader } from "react-content-loader" +import { FieldWrap, Field, Key, Value, FieldGroup } from "./common" +import { getDuration } from "../../util/helpers" +import { TertiaryButton } from "../button" +import { css } from "emotion" +import { SelectEntity } from "../../contexts/ui" +import { Test, Task } from "../../contexts/api" +import { + EntityCardWrap, + Header, + Label, + Name, + StateLabel, + Content, +} from "./common" + +export type Props = Pick & Pick & { + moduleName: string, + isLoading: boolean, + showInfo: boolean, + onEntitySelected: SelectEntity, +} + +// FIXME: Use a single card for Test and Task, they're basically the same. +export const TaskCard = ({ + name, + dependencies, + state, + startedAt, + completedAt, + moduleName, + isLoading, + showInfo, + onEntitySelected, +}: Props) => { + const duration = startedAt && completedAt && getDuration(startedAt, completedAt) + + const handleEntitySelected = () => { + if (moduleName && name) { + onEntitySelected({ + type: "task", + name, + module: moduleName, + }) + } + } + + return ( + +
+
+ + {name} +
+ {state && ( + + {state} + + )} +
+ + {isLoading && ( + + )} + {!isLoading && ( + + 0}> + Depends on: + {dependencies.join(", ")} + + + + Ran: + {moment(startedAt).fromNow()} + + + Took: + {duration} + + +
+
+ + Show result + +
+
+
+ )} +
+
+ ) +} diff --git a/dashboard/src/components/entity-cards/test-card.tsx b/dashboard/src/components/entity-cards/test-card.tsx new file mode 100644 index 0000000000..3f5edce03f --- /dev/null +++ b/dashboard/src/components/entity-cards/test-card.tsx @@ -0,0 +1,118 @@ +/* + * 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 React from "react" +import moment from "moment" +import { Facebook as ContentLoader } from "react-content-loader" +import { FieldWrap, Field, Key, Value, FieldGroup } from "./common" +import { getDuration } from "../../util/helpers" +import { TertiaryButton } from "../button" +import { css } from "emotion" +import { SelectEntity } from "../../contexts/ui" +import { Test } from "../../contexts/api" +import { + EntityCardWrap, + Header, + Label, + Name, + StateLabel, + Content, +} from "./common" + +export type Props = Pick & Pick & { + moduleName: string, + isLoading: boolean, + showInfo: boolean, + onEntitySelected: SelectEntity, +} + +// FIXME: Use a single card for Test and Task, they're basically the same. +export const TestCard = ({ + name, + dependencies, + state, + startedAt, + completedAt, + moduleName, + isLoading, + showInfo, + onEntitySelected, +}: Props) => { + const duration = startedAt && completedAt && getDuration(startedAt, completedAt) + + const handleEntitySelected = () => { + if (moduleName && name) { + onEntitySelected({ + type: "test", + name, + module: moduleName, + }) + } + } + + return ( + +
+
+ + {name} +
+ {state && ( + + {state} + + )} +
+ + {isLoading && ( + + )} + {!isLoading && ( + + 0}> + Depends on: + {dependencies.join(", ")} + + + + Ran: + {moment(startedAt).fromNow()} + + + Took: + {duration} + + +
+
+ + Show result + +
+
+
+ )} +
+
+ ) +} diff --git a/dashboard/src/components/entity-result.tsx b/dashboard/src/components/entity-result.tsx index eb6eb19662..27ec89dd02 100644 --- a/dashboard/src/components/entity-result.tsx +++ b/dashboard/src/components/entity-result.tsx @@ -15,8 +15,8 @@ import styled from "@emotion/styled" import Card from "./card" import { colors } from "../styles/variables" import { WarningNotification } from "./notifications" -import { ActionIcon } from "./ActionIcon" -import { EntityResultSupportedTypes } from "../context/ui" +import { ActionIcon } from "./action-icon" +import { EntityResultSupportedTypes } from "../contexts/ui" const Term = styled.div` background-color: ${colors.gardenBlack}; diff --git a/dashboard/src/components/graph/index.tsx b/dashboard/src/components/graph/index.tsx index d27fe59e21..83e43ca77d 100644 --- a/dashboard/src/components/graph/index.tsx +++ b/dashboard/src/components/graph/index.tsx @@ -14,16 +14,15 @@ import { capitalize } from "lodash" import { event, select, selectAll } from "d3-selection" import { zoom, zoomIdentity } from "d3-zoom" import dagreD3 from "dagre-d3" -import { Extends } from "garden-service/build/src/util/util" -import { ConfigDump } from "garden-service/build/src/garden" +import { PickFromUnion } from "garden-service/build/src/util/util" import Card from "../card" import "./graph.scss" import { colors, fontMedium } from "../../styles/variables" import Spinner, { SpinnerProps } from "../spinner" -import { SelectGraphNode, StackGraphSupportedFilterKeys } from "../../context/ui" -import { WsEventMessage, SupportedEventName } from "../../context/events" -import { GraphOutputWithNodeStatus } from "../../context/data" +import { SelectGraphNode, StackGraphSupportedFilterKeys } from "../../contexts/ui" +import { SupportedEventName } from "../../contexts/api" import { FiltersButton, Filters } from "../group-filter" +import { GraphOutputWithNodeStatus } from "../../containers/graph" interface Node { name: string @@ -45,7 +44,7 @@ export interface Graph { } // FIXME: We shouldn't repeat the keys for both the type and the set below -type TaskNodeEventName = Extends< +type TaskNodeEventName = PickFromUnion< SupportedEventName, "taskPending" | "taskProcessing" | "taskComplete" | "taskError" > @@ -211,12 +210,11 @@ type ChartState = { } interface Props { - config: ConfigDump graph: GraphOutputWithNodeStatus onGraphNodeSelected: SelectGraphNode selectedGraphNode: string | null - layoutChanged: boolean - message?: WsEventMessage + layoutChanged: boolean // set whenever user toggles sidebar + isProcessing: boolean // set whenever wsMessages are received filters: Filters onFilter: (filterKey: StackGraphSupportedFilterKeys) => void } @@ -319,12 +317,11 @@ class Chart extends Component { } render() { - const { message } = this.props const chartHeightEstimate = `100vh - 2rem` let spinner: React.ReactNode = null let status = "" - if (message && message.name !== "taskGraphComplete") { + if (this.props.isProcessing) { status = "Processing..." spinner = } diff --git a/dashboard/src/components/ingresses.tsx b/dashboard/src/components/ingresses.tsx index 3b31a19164..0b82bf37d6 100644 --- a/dashboard/src/components/ingresses.tsx +++ b/dashboard/src/components/ingresses.tsx @@ -6,14 +6,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useContext } from "react" +import React from "react" import styled from "@emotion/styled" import { ExternalLink } from "./links" import { ServiceIngress } from "garden-service/build/src/types/service" import { truncateMiddle } from "../util/helpers" import normalizeUrl from "normalize-url" import { format } from "url" -import { UiStateContext } from "../context/ui" +import { useUiState } from "../contexts/ui" const Ingresses = styled.div` font-size: 1rem; @@ -56,7 +56,7 @@ interface IngressesProp { } export default ({ ingresses }: IngressesProp) => { - const { actions: { selectIngress } } = useContext(UiStateContext) + const { actions: { selectIngress } } = useUiState() const handleSelectIngress = (event) => { if (ingresses && ingresses.length) { diff --git a/dashboard/src/components/logs.tsx b/dashboard/src/components/logs.tsx index 3611cdd561..0f3ef47d95 100644 --- a/dashboard/src/components/logs.tsx +++ b/dashboard/src/components/logs.tsx @@ -9,24 +9,21 @@ import cls from "classnames" import { css } from "emotion" import styled from "@emotion/styled" -import { max } from "lodash" +import { max, flatten } from "lodash" import React, { Component } from "react" import Select from "react-select" import Terminal from "./terminal" import Card, { CardTitle } from "./card" import { colors } from "../styles/variables" -import { LoadLogs } from "../context/data" -import { getServiceNames } from "../util/helpers" +import { LoadLogs } from "../contexts/api" import { ServiceLogEntry } from "garden-service/build/src/types/plugin/service/getServiceLogs" -import { ConfigDump } from "garden-service/build/src/garden" -import { ActionIcon } from "./ActionIcon" +import { ActionIcon } from "./action-icon" interface Props { - config: ConfigDump - logs: ServiceLogEntry[] - loadLogs: LoadLogs + logs: { [serviceName: string]: ServiceLogEntry[] } + onRefresh: LoadLogs } interface State { @@ -84,21 +81,25 @@ class Logs extends Component { } refresh() { - this.props.loadLogs(getServiceNames(this.props.config.moduleConfigs), true) + const serviceNames = Object.keys(this.props.logs) + if (!serviceNames.length) { + return + } + this.props.onRefresh({ serviceNames, force: true }) this.setState({ loading: true }) } render() { - const { config, logs } = this.props + const { logs } = this.props const { loading, selectedService } = this.state - const serviceNames = getServiceNames(config.moduleConfigs) + const serviceNames = Object.keys(logs) const maxServiceName = (max(serviceNames) || []).length const options = [{ value: "all", label: "All service logs" }] .concat(serviceNames.map(name => ({ value: name, label: name }))) const { value, label } = selectedService const title = value === "all" ? label : `${label} logs` - const filteredLogs = value === "all" ? logs : logs.filter(l => l.serviceName === value) + const filteredLogs = value === "all" ? flatten(Object.values(logs)) : logs[value] return (
diff --git a/dashboard/src/components/module.tsx b/dashboard/src/components/module.tsx deleted file mode 100644 index fb10867d82..0000000000 --- a/dashboard/src/components/module.tsx +++ /dev/null @@ -1,314 +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 React, { useState, useContext } from "react" -import styled from "@emotion/styled" -import { css } from "emotion" -import moment from "moment" -import { ModuleModel } from "../containers/overview" -import EntityCard from "./entity-card" -import { UiStateContext } from "../context/ui" -import Ingresses from "./ingresses" -import { TertiaryButton } from "./button" - -const Module = styled.div` - padding: 1.2rem; - background: white; - box-shadow: 0rem 0.375rem 1.125rem rgba(0, 0, 0, 0.06); - border-radius: 0.25rem; - margin: 0 1.3rem 1.3rem 0; - min-width: 17.5rem; - flex: 1 1; - max-width: 20rem; -` - -type EntityCardsProps = { - visible: boolean, -} -const EntityCards = styled.div` - padding-top: 1rem; - display: flex; - flex-wrap: wrap; - align-items: middle; - display: ${props => (props.visible ? `block` : "none")}; - animation: fadein .5s ; - - @keyframes fadein { - from { - opacity: 0; - } - to { - opacity: 1; - } - } -` - -type FieldsProps = { - visible: boolean, -} -const Fields = styled.div` - display: ${props => (props.visible ? `block` : "none")}; - animation: fadein .5s; - @keyframes fadein { - from { - opacity: 0; - } - to { - opacity: 1; - } - } -` - -const Header = styled.div` - line-height: 1rem; - display: flex; - align-items: baseline; - align-self: flex-start; - justify-content: space-between; -` - -const Name = styled.div` - font-weight: 500; - font-size: 0.9375rem; - letter-spacing: 0.01em; - color: #323C47; -` - -const Tag = styled.div` - padding-left: .5rem; - font-weight: 500; - font-size: 0.625rem; - letter-spacing: 0.01em; - color: #90A0B7; -` - -type FieldProps = { - inline?: boolean, - visible: boolean, -} -const Field = styled.div` - display: ${props => (props.visible ? (props.inline ? "flex" : "block") : "none")}; - flex-direction: row; -` - -type FieldGroupProps = { - visible: boolean, -} -const FieldGroup = styled.div` - display: ${props => (props.visible ? "flex" : "none")}; - flex-direction: row; - padding-top: .25rem; -` - -const Key = styled.div` - padding-right: .25rem; - font-size: 0.8125rem; - line-height: 1.1875rem; - letter-spacing: 0.01em; - color: #4C5862; - opacity: 0.5; -` - -const Value = styled.div` - padding-right: .5rem; - font-size: 0.8125rem; - line-height: 1.1875rem; - letter-spacing: 0.01em; -` - -const Description = styled(Field)` - color: #4C5862; - opacity: 0.5; - padding-top: .25rem; -` - -const Full = styled(Value)` - cursor: pointer; -` - -const Short = styled(Value)` - cursor: pointer; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -` - -interface ModuleProp { - module: ModuleModel -} -export default ({ - module: { services = [], tests = [], tasks = [], name, type, description }, -}: ModuleProp) => { - const { - state: { overview: { filters } }, - actions: { selectEntity }, - } = useContext(UiStateContext) - - const [isValueExpended, setValueExpendedState] = useState(false) - const toggleValueExpendedState = () => (setValueExpendedState(!isValueExpended)) - const handleSelectEntity = ( - { - moduleName, - entityName, - entityType, - }: - { - moduleName: string, - entityName: string, - entityType: "test" | "task", - }) => { - if (moduleName && entityName && entityType) { - selectEntity({ - type: entityType, - name: entityName, - module: moduleName, - }) - } - } - - return ( - -
- {name} - {type && type.toUpperCase()} MODULE -
- - - {!isValueExpended && ( - {description} - )} - {isValueExpended && ( - {description} - )} - - - 0}> - {services.map(service => ( - - - 0}> - Depends on: - {service.dependencies.join(", ")} - - 0}> - - - - - ))} - - 0}> - {tests.map(test => ( - - - 0}> - Depends on: - {test.dependencies.join(", ")} - - - - Ran: - {moment(test.startedAt).fromNow()} - - - Took: - {test.duration} - - -
-
- -
-
-
-
- ))} -
- 0}> - {tasks.map(task => ( - - - 0}> - Depends on: - {task.dependencies.join(", ")} - - - - Ran: - {moment(task.startedAt).fromNow()} - - - Took: - {task.duration} - - -
-
- -
-
-
-
- ))} -
-
- ) -} - -const ShowResultButton = ({ - entityName, - entityType, - moduleName, - onClick, -}: { - entityName: string, - entityType: "test" | "task", - moduleName: string, - onClick, -}) => { - const handleClick = () => onClick({ entityName, moduleName, entityType }) - return ( - - Show result - - ) -} diff --git a/dashboard/src/components/view-ingress.tsx b/dashboard/src/components/view-ingress.tsx index 99f9a52b01..1b25779698 100644 --- a/dashboard/src/components/view-ingress.tsx +++ b/dashboard/src/components/view-ingress.tsx @@ -6,15 +6,15 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useContext } from "react" +import React from "react" import styled from "@emotion/styled" import { ExternalLink } from "./links" import { ServiceIngress } from "garden-service/build/src/types/service" import { truncateMiddle } from "../util/helpers" import normalizeUrl from "normalize-url" import { format } from "url" -import { UiStateContext } from "../context/ui" -import { ActionIcon } from "./ActionIcon" +import { useUiState } from "../contexts/ui" +import { ActionIcon } from "./action-icon" const ViewIngress = styled.div` ` @@ -74,7 +74,7 @@ interface ViewIngressProp { } export default ({ ingress, height, width }: ViewIngressProp) => { - const { actions: { selectIngress } } = useContext(UiStateContext) + const { actions: { selectIngress } } = useUiState() const removeSelectedIngress = () => { selectIngress(null) diff --git a/dashboard/src/containers/entity-result.tsx b/dashboard/src/containers/entity-result.tsx index b53ed53131..070c4f864f 100644 --- a/dashboard/src/containers/entity-result.tsx +++ b/dashboard/src/containers/entity-result.tsx @@ -6,14 +6,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useContext, useEffect } from "react" -import { DataContext } from "../context/data" +import React, { useEffect } from "react" +import { useApi } from "../contexts/api" import { getDuration } from "../util/helpers" import EntityResult from "../components/entity-result" import { TaskResultOutput } from "garden-service/build/src/commands/get/get-task-result" import { TestResultOutput } from "garden-service/build/src/commands/get/get-test-result" import { ErrorNotification } from "../components/notifications" -import { EntityResultSupportedTypes } from "../context/ui" +import { EntityResultSupportedTypes } from "../contexts/ui" const ErrorMsg = ({ error, type }) => ( @@ -48,42 +48,60 @@ interface Props { export default ({ name, moduleName, type, onClose }: Props) => { const { actions: { loadTestResult, loadTaskResult }, - store: { testResult, taskResult }, - } = useContext(DataContext) + store: { entities: { tasks, tests }, requestStates: { fetchTestResult, fetchTaskResult } }, + } = useApi() const loadResults = () => { if (type === "test") { - loadTestResult({ name, module: moduleName }, true) + loadTestResult({ name, moduleName, force: true }) } else if (type === "run" || type === "task") { - loadTaskResult({ name }, true) + loadTaskResult({ name, force: true }) } } useEffect(loadResults, [name, moduleName]) - // Here we just render the node data since only nodes of types test and run have results - if (!(type === "test" || type === "run" || type === "task")) { + if (type === "test") { + const testResult = tests && tests[name] && tests[name].result + + if (fetchTestResult.error) { + return + } + return ( ) - } - const result = type === "test" ? testResult : taskResult + } else if (type === "task" || type === "run") { + const taskResult = tasks && tasks[name] && tasks[name].result - if (result.error) { - return - } + if (fetchTaskResult.error) { + return + } - // Loading. Either data hasn't been loaded at all or cache contains stale data - if (!result.data || result.data.name !== name) { return ( + ) + } else { + return ( + { /> ) } - - // Render info pane with result data - return ( - - ) } diff --git a/dashboard/src/containers/graph.tsx b/dashboard/src/containers/graph.tsx index 73851a900e..177425b052 100644 --- a/dashboard/src/containers/graph.tsx +++ b/dashboard/src/containers/graph.tsx @@ -6,55 +6,93 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useContext, useEffect } from "react" +import React, { useEffect } from "react" import styled from "@emotion/styled" import Graph from "../components/graph" import PageError from "../components/page-error" -import { EventContext } from "../context/events" -import { DataContext } from "../context/data" -import { UiStateContext, StackGraphSupportedFilterKeys, EntityResultSupportedTypes } from "../context/ui" +import { TaskState, useApi } from "../contexts/api" +import { + StackGraphSupportedFilterKeys, + EntityResultSupportedTypes, + useUiState, +} from "../contexts/ui" import EntityResult from "./entity-result" import Spinner from "../components/spinner" 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" const Wrapper = styled.div` -padding-left: .75rem; + padding-left: .75rem; ` +export interface RenderedNodeWithStatus extends RenderedNode { + status?: TaskState +} +export interface GraphOutputWithNodeStatus extends GraphOutput { + nodes: RenderedNodeWithStatus[], +} + export default () => { const { actions: { loadGraph, loadConfig }, - store: { config, graph }, - } = useContext(DataContext) - const { message } = useContext(EventContext) + store: { entities: { modules, services, tests, tasks, graph }, requestStates: { fetchGraph, fetchTaskStates } }, + } = useApi() - useEffect(loadConfig, []) - useEffect(loadGraph, []) + useEffect(() => { + async function fetchData() { + return await loadConfig() + } + fetchData() + }, []) + + useEffect(() => { + async function fetchData() { + return await loadGraph() + } + fetchData() + }, []) const { actions: { selectGraphNode, stackGraphToggleItemsView, clearGraphNodeSelection }, state: { selectedGraphNode, isSidebarOpen, stackGraph: { filters } }, - } = useContext(UiStateContext) + } = useUiState() - if (config.error || graph.error) { - return + if (fetchGraph.error) { + return } - if (!config.data || !graph.data || config.loading || graph.loading) { + if (fetchGraph.loading) { return } - if (message && message.type === "event") { - const nodeToUpdate = graph.data.nodes.find(node => node.key === (message.payload && message.payload["key"])) - if (nodeToUpdate) { - nodeToUpdate.status = message.name - graph.data = { ...graph.data } + + const nodesWithStatus: RenderedNodeWithStatus[] = graph.nodes.map(node => { + let taskState: TaskState = "taskComplete" + switch (node.type) { + case "publish": + break + case "deploy": + taskState = services[node.name] && services[node.name].taskState || taskState + break + case "build": + taskState = modules[node.name] && modules[node.name].taskState || taskState + break + case "run": + taskState = tasks[node.name] && tasks[node.name].taskState || taskState + break + case "test": + taskState = tests[node.name] && tests[node.name].taskState || taskState + break } - } + return { ...node, status: taskState } + }) + + let graphWithStatus: GraphOutputWithNodeStatus = { nodes: nodesWithStatus, relationships: graph.relationships } let moreInfoPane: React.ReactNode = null - if (selectedGraphNode && graph.data) { - const node = graph.data.nodes.find(n => n.key === selectedGraphNode) + if (selectedGraphNode && graph) { + const node = graph.nodes.find(n => n.key === selectedGraphNode) if (node) { moreInfoPane = (
@@ -89,10 +127,10 @@ export default () => { onGraphNodeSelected={selectGraphNode} selectedGraphNode={selectedGraphNode} layoutChanged={isSidebarOpen} - config={config.data} - graph={graph.data} + graph={graphWithStatus} filters={graphFilters} onFilter={stackGraphToggleItemsView} + isProcessing={fetchTaskStates.loading} />
{moreInfoPane} diff --git a/dashboard/src/containers/logs.tsx b/dashboard/src/containers/logs.tsx index 8898fa4f1f..d73d638fba 100644 --- a/dashboard/src/containers/logs.tsx +++ b/dashboard/src/containers/logs.tsx @@ -6,52 +6,49 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useContext, useEffect } from "react" +import React, { useEffect } from "react" import PageError from "../components/page-error" import Logs from "../components/logs" -import { DataContext } from "../context/data" -import { getServiceNames } from "../util/helpers" +import { useApi } from "../contexts/api" import Spinner from "../components/spinner" export default () => { const { - actions: { loadConfig }, - store: { config }, - } = useContext(DataContext) + actions: { loadConfig, loadLogs }, + store: { entities: { logs, services }, requestStates: { fetchLogs, fetchConfig } }, + } = useApi() - useEffect(loadConfig, []) - - if (!config.data || config.loading) { - return - } - - return -} - -const LogsContainer = () => { - const { - actions: { loadLogs }, - store: { config, logs }, - } = useContext(DataContext) + const serviceNames: string[] = Object.keys(services) useEffect(() => { - if (config.data) { - loadLogs(getServiceNames(config.data.moduleConfigs)) + async function fetchData() { + return await loadConfig() } + fetchData() }, []) - if (!logs.data || !config.data) { + useEffect(() => { + async function fetchData() { + return await loadLogs({ serviceNames }) + } + + if (serviceNames.length) { + fetchData() + } + }, [fetchConfig.didFetch]) // run again only after config had been fetched + + if (fetchConfig.loading || fetchLogs.loading) { return } - if (logs.error || config.error) { + if (fetchConfig.error || fetchLogs.error) { return ( - + ) } return ( - + ) } diff --git a/dashboard/src/containers/overview.tsx b/dashboard/src/containers/overview.tsx index 897e24507d..f64091e8d9 100644 --- a/dashboard/src/containers/overview.tsx +++ b/dashboard/src/containers/overview.tsx @@ -6,28 +6,28 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useContext, useEffect } from "react" -import PageError from "../components/page-error" +import React, { useEffect } from "react" import styled from "@emotion/styled" -import { ServiceIngress } from "garden-service/build/src/types/service" + import { RunState } from "garden-service/build/src/commands/get/get-status" -import Module from "../components/module" -import EntityResult from "../containers/entity-result" -import { default as ViewIngress } from "../components/view-ingress" -import { DataContext } from "../context/data" -import Spinner from "../components/spinner" import { ServiceState } from "garden-service/build/src/types/service" -import { UiStateContext } from "../context/ui" -import { getDuration } from "../util/helpers" -export const overviewConfig = { - service: { - height: "14rem", - }, -} +import PageError from "../components/page-error" +import { ModuleCard, Props as ModuleProps } from "../components/entity-cards/module" +import EntityResult from "./entity-result" +import ViewIngress from "../components/view-ingress" +import { + Service, + Test, + Task, + Module, + useApi, +} from "../contexts/api" +import Spinner from "../components/spinner" +import { useUiState } from "../contexts/ui" const Overview = styled.div` - padding-top: .5rem; + padding-top: .5rem; ` const Modules = styled.div` @@ -38,46 +38,52 @@ const Modules = styled.div` padding: 0 0 0 1rem; ` -export type ModuleModel = { - name: string; - type: string; - path?: string; - repositoryUrl?: string; - description?: string; - services: Service[]; - tests: Test[]; - tasks: Task[]; -} export type Entity = { - name: string; + name?: string; state?: ServiceState | RunState; - isLoading: boolean; dependencies: string[]; } -export interface Service extends Entity { - state?: ServiceState - ingresses?: ServiceIngress[] + +const mapServices = (serviceEntities: Service[]): ModuleProps["serviceCardProps"] => { + return serviceEntities.map(({ config, status }) => ({ + name: config.name, + dependencies: config.dependencies || [], + state: status.state, + ingresses: status.ingresses, + })) } -export interface Test extends Entity { - startedAt?: Date - completedAt?: Date - duration?: string - state?: RunState + +const mapTests = (testEntities: Test[], moduleName: string): ModuleProps["testCardProps"] => { + return testEntities.map(({ config, status }) => ({ + name: config.name, + dependencies: config.dependencies || [], + state: status.state, + startedAt: status.startedAt, + completedAt: status.completedAt, + moduleName, + })) } -export interface Task extends Entity { - startedAt?: Date - completedAt?: Date - duration?: string - state?: RunState + +const mapTasks = (taskEntities: Task[], moduleName: string): ModuleProps["taskCardProps"] => { + return taskEntities.map(({ config, status }) => ({ + name: config.name, + dependencies: config.dependencies || [], + state: status.state, + startedAt: status.startedAt, + completedAt: status.completedAt, + moduleName, + })) } -// Note: We render the overview page components individually so we that we don't -// have to wait for both API calls to return. export default () => { const { + store: { + projectRoot, + entities: { modules, services, tests, tasks }, + requestStates: { fetchConfig, fetchStatus }, + }, actions: { loadConfig, loadStatus }, - store: { config, status }, - } = useContext(DataContext) + } = useApi() const { state: { @@ -86,143 +92,85 @@ export default () => { actions: { selectEntity, }, - } = useContext(UiStateContext) + } = useUiState() + + // TODO use useAsyncEffect? + // https://dev.to/n1ru4l/homebrew-react-hooks-useasynceffect-or-how-to-handle-async-operations-with-useeffect-1fa8 + useEffect(() => { + async function fetchData() { + return await loadConfig() + } + fetchData() + }, []) - useEffect(loadConfig, []) - useEffect(loadStatus, []) + useEffect(() => { + async function fetchData() { + return await loadStatus() + } + fetchData() + }, []) const clearSelectedEntity = () => { selectEntity(null) } - const isLoadingConfig = !config.data || config.loading - - let modulesContainerComponent: React.ReactNode = null - let modules: ModuleModel[] = [] - - if (config.error || status.error) { - modulesContainerComponent = - } else if (isLoadingConfig) { - modulesContainerComponent = - } else if (config.data && config.data.moduleConfigs) { - - // fill modules with services names - modules = config.data.moduleConfigs.map(moduleConfig => ({ - name: moduleConfig.name, - type: moduleConfig.type, - path: config.data && - config.data.projectRoot && - config.data.projectRoot.split("/").pop() + - moduleConfig.path.replace(config.data.projectRoot, ""), - repositoryUrl: moduleConfig.repositoryUrl, - description: moduleConfig.description, - services: moduleConfig.serviceConfigs.map(service => ({ - name: service.name, - isLoading: true, - dependencies: service.dependencies, - })), - tests: moduleConfig.testConfigs.map(test => ({ - name: test.name, - isLoading: true, - dependencies: test.dependencies, - })), - tasks: moduleConfig.taskConfigs.map(task => ({ - name: task.name, - isLoading: true, - dependencies: task.dependencies, - })), - })) - - // fill missing data from status - if (status.data && status.data.services) { - const servicesStatus = status.data.services - const testsStatus = status.data.tests - const tasksStatus = status.data.tasks - for (let currModule of modules) { - for (let serviceName of Object.keys(servicesStatus)) { - const index = currModule.services.findIndex(s => s.name === serviceName) - - if (index !== -1) { - currModule.services[index] = { - ...currModule.services[index], - state: servicesStatus[serviceName].state, - ingresses: servicesStatus[serviceName].ingresses, - isLoading: false, - } - } - } + if (fetchConfig.error || fetchStatus.error) { + return + } - for (let testName of Object.keys(testsStatus)) { - const index = currModule.tests.findIndex(t => t.name === testName.split(".")[1]) - - if (index !== -1) { - const testStatus = testsStatus[testName] - currModule.tests[index] = { - ...currModule.tests[index], - state: testStatus.state, - isLoading: false, - startedAt: testStatus.startedAt, - completedAt: testStatus.completedAt, - duration: testStatus.startedAt && - testStatus.completedAt && - getDuration(testStatus.startedAt, testStatus.completedAt), - } - } - } + if (fetchConfig.loading || fetchStatus.loading) { + return + } - for (let taskName of Object.keys(tasksStatus)) { - const index = currModule.tasks.findIndex(t => t.name === taskName) - - if (index !== -1) { - const taskStatus = tasksStatus[taskName] - currModule.tasks[index] = { - ...currModule.tasks[index], - state: taskStatus.state, - isLoading: false, - startedAt: taskStatus.startedAt, - completedAt: taskStatus.completedAt, - duration: taskStatus.startedAt && - taskStatus.completedAt && - getDuration(taskStatus.startedAt, taskStatus.completedAt), - } - } - } - } + const moduleProps: ModuleProps[] = Object.values(modules).map((module: Module) => { + const serviceEntities = module.services.map(serviceKey => services[serviceKey]) || [] + const testEntities = module.tests.map(testKey => tests[testKey]) || [] + const taskEntities = module.tasks.map(taskKey => tasks[taskKey]) || [] + + return { + name: module.name, + type: module.type, + path: projectRoot.split("/").pop() + module.path.replace(projectRoot, ""), + repositoryUrl: module.repositoryUrl, + description: module.description, + serviceCardProps: mapServices(serviceEntities), + testCardProps: mapTests(testEntities, module.name), + taskCardProps: mapTasks(taskEntities, module.name), + isLoading: fetchStatus.loading, } + }) - modulesContainerComponent = ( - -
-
- - {modules.map(module => ( - - ))} - -
- {selectedIngress && -
- {selectedIngress && - - } -
- } - {selectedEntity && ( -
- +
+
+ + {moduleProps.map(props => ( + -
- )} + ))} +
- - ) - } - - return ( -
{modulesContainerComponent}
+ {selectedIngress && +
+ {selectedIngress && + + } +
+ } + {selectedEntity && ( +
+ +
+ )} +
+ ) } diff --git a/dashboard/src/containers/sidebar.tsx b/dashboard/src/containers/sidebar.tsx index d01ec349db..4308ea737a 100644 --- a/dashboard/src/containers/sidebar.tsx +++ b/dashboard/src/containers/sidebar.tsx @@ -7,10 +7,10 @@ */ import { kebabCase, flatten, entries } from "lodash" -import React, { useContext, useEffect } from "react" +import React, { useEffect } from "react" import Sidebar from "../components/sidebar" -import { DataContext } from "../context/data" +import { useApi } from "../contexts/api" import { DashboardPage } from "garden-service/build/src/config/status" export interface Page extends DashboardPage { @@ -44,22 +44,25 @@ const builtinPages: Page[] = [ const SidebarContainer = () => { const { actions: { loadStatus }, - store: { status }, - } = useContext(DataContext) + store: { entities: { providers } }, + } = useApi() - useEffect(loadStatus, []) + useEffect(() => { + async function fetchData() { + return await loadStatus() + } + fetchData() + }, []) let pages: Page[] = [] - if (status.data) { - pages = flatten(entries(status.data.providers).map(([providerName, providerStatus]) => { - return (providerStatus.dashboardPages || []).map(p => ({ - ...p, - path: `/provider/${providerName}/${kebabCase(p.title)}`, - description: p.description + ` (from provider ${providerName})`, - })) + pages = flatten(entries(providers).map(([providerName, providerStatus]) => { + return (providerStatus.dashboardPages || []).map(p => ({ + ...p, + path: `/provider/${providerName}/${kebabCase(p.title)}`, + description: p.description + ` (from provider ${providerName})`, })) - } + })) return } diff --git a/dashboard/src/context/data.tsx b/dashboard/src/context/data.tsx deleted file mode 100644 index 2a95883fe6..0000000000 --- a/dashboard/src/context/data.tsx +++ /dev/null @@ -1,230 +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 { useReducer } from "react" -import React from "react" - -import { - fetchConfig, - fetchLogs, - fetchStatus, - fetchGraph, - FetchLogsParam, - FetchTaskResultParam, - FetchTestResultParam, - fetchTaskResult, - fetchTestResult, -} from "../api/api" -import { ServiceLogEntry } from "garden-service/build/src/types/plugin/service/getServiceLogs" -import { ConfigDump } from "garden-service/build/src/garden" -import { GraphOutput } from "garden-service/build/src/commands/get/get-graph" -import { TaskResultOutput } from "garden-service/build/src/commands/get/get-task-result" -import { StatusCommandResult } from "garden-service/build/src/commands/get/get-status" -import { TestResultOutput } from "garden-service/build/src/commands/get/get-test-result" -import { AxiosError } from "axios" -import { RenderedNode } from "garden-service/build/src/config-graph" -import { SupportedEventName } from "./events" - -interface StoreCommon { - error?: AxiosError - loading: boolean -} - -export interface RenderedNodeWithStatus extends RenderedNode { - status?: SupportedEventName -} -export interface GraphOutputWithNodeStatus extends GraphOutput { - nodes: RenderedNodeWithStatus[], -} - -// This is the global data store -interface Store { - config: StoreCommon & { - data?: ConfigDump, - }, - status: StoreCommon & { - data?: StatusCommandResult, - }, - graph: StoreCommon & { - data?: GraphOutputWithNodeStatus, - }, - logs: StoreCommon & { - data?: ServiceLogEntry[], - }, - taskResult: StoreCommon & { - data?: TaskResultOutput, - }, - testResult: StoreCommon & { - data?: TestResultOutput, - }, -} - -type Context = { - store: Store; - actions: Actions; -} - -type StoreKey = keyof Store -const storeKeys: StoreKey[] = [ - "config", - "status", - "graph", - "logs", - "taskResult", - "testResult", -] - -interface ActionBase { - type: "fetchStart" | "fetchSuccess" | "fetchFailure" - key: StoreKey -} - -interface ActionStart extends ActionBase { - type: "fetchStart" -} - -interface ActionSuccess extends ActionBase { - type: "fetchSuccess" - data: any -} - -interface ActionError extends ActionBase { - type: "fetchFailure" - error: AxiosError -} - -type Action = ActionStart | ActionError | ActionSuccess - -export type LoadLogs = (param: FetchLogsParam, force?: boolean) => void -export type LoadTaskResult = (param: FetchTaskResultParam, force?: boolean) => void -export type LoadTestResult = (param: FetchTestResultParam, force?: boolean) => void - -type Loader = (force?: boolean) => void -interface Actions { - loadLogs: LoadLogs - loadConfig: Loader - loadStatus: Loader - loadGraph: Loader - loadTaskResult: LoadTaskResult - loadTestResult: LoadTestResult -} - -const initialState: Store = storeKeys.reduce((acc, key) => { - const state = { loading: false } - acc[key] = state - return acc -}, {} as Store) - -/** - * Updates slices of the store based on the slice key - */ -function updateSlice( - prevState: Store, - key: StoreKey, - sliceState: Partial, -): Store { - const prevSliceState = prevState[key] - return { - ...prevState, - [key]: { - ...prevSliceState, - ...sliceState, - }, - } -} - -/** - * The reducer for the useApi hook. Sets the state for a given slice of the store on fetch events. - */ -function reducer(store: Store, action: Action) { - switch (action.type) { - case "fetchStart": - return updateSlice(store, action.key, { loading: true, error: undefined }) - case "fetchSuccess": - return updateSlice(store, action.key, { loading: false, data: action.data, error: undefined }) - case "fetchFailure": - return updateSlice(store, action.key, { loading: false, error: action.error }) - } -} - -/** - * Creates the actions needed for fetching data from the API and updates the store state as the actions are called. - * - * TODO: Improve type safety - */ -function useApi() { - const [store, dispatch] = useReducer(reducer, initialState) - - const fetch = async (key: StoreKey, fetchFn: Function, args?: any[]) => { - dispatch({ key, type: "fetchStart" }) - - try { - const res = args ? await fetchFn(...args) : await fetchFn() - dispatch({ key, type: "fetchSuccess", data: res }) - } catch (error) { - dispatch({ key, error, type: "fetchFailure" }) - } - } - - const fetchOrReadFromStore = (key: StoreKey, action: T, force: boolean, args: any[] = []) => { - const { data, loading } = store[key] - if (!force && (data || loading)) { - return - } - fetch(key, action, args).catch(error => dispatch({ key, error, type: "fetchFailure" })) - } - - const loadLogs: LoadLogs = (args: FetchLogsParam, force: boolean = false) => ( - fetchOrReadFromStore("logs", fetchLogs, force, [args]) - ) - const loadConfig: Loader = (force: boolean = false) => ( - fetchOrReadFromStore("config", fetchConfig, force) - ) - const loadGraph: Loader = (force: boolean = false) => ( - fetchOrReadFromStore("graph", fetchGraph, force) - ) - const loadStatus: Loader = (force: boolean = false) => ( - fetchOrReadFromStore("status", fetchStatus, force) - ) - const loadTaskResult: LoadTaskResult = (args: FetchTaskResultParam, force: boolean = false) => { - return fetchOrReadFromStore("taskResult", fetchTaskResult, force, [args]) - } - const loadTestResult: LoadTestResult = (args: FetchTestResultParam, force: boolean = false) => { - return fetchOrReadFromStore("testResult", fetchTestResult, force, [args]) - } - - return { - store, - actions: { - loadConfig, - loadLogs, - loadGraph, - loadStatus, - loadTaskResult, - loadTestResult, - }, - } -} - -// We type cast the initial value to avoid having to check whether the context exists in every context consumer. -// Context is only undefined if the provider is missing which we assume is not the case. -export const DataContext = React.createContext({} as Context) - -/** - * This component manages the "rest" API data state (not the websockets) for the entire application. - * We use the new React Hooks API to pass store data and actions down the component tree. - */ -export const DataProvider: React.FC = ({ children }) => { - const storeAndActions = useApi() - - return ( - - {children} - - ) -} diff --git a/dashboard/src/context/events.tsx b/dashboard/src/context/events.tsx deleted file mode 100644 index 7606fefd0c..0000000000 --- a/dashboard/src/context/events.tsx +++ /dev/null @@ -1,92 +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 React from "react" - -import { useEffect, useState } from "react" -import { ServerWebsocketMessage } from "garden-service/build/src/server/server" -import { Events, EventName } from "garden-service/build/src/events" - -import getApiUrl from "../api/get-api-url" -import { Extends } from "garden-service/build/src/util/util" - -// FIXME: We shouldn't repeat the keys for both the type and the set below -export type SupportedEventName = Extends< - EventName, "taskPending" | "taskProcessing" | "taskComplete" | "taskGraphComplete" | "taskError" | "taskCancelled" -> - -export const supportedEventNames: Set = new Set( - ["taskPending", "taskProcessing", "taskComplete", "taskGraphComplete", "taskError", "taskCancelled"], -) - -export type WsEventMessage = ServerWebsocketMessage & { - type: "event", - name: SupportedEventName, - payload: Events[SupportedEventName], -} - -/** - * Type guard to check whether websocket message is a type supported by the Dashboard - */ -function isSupportedEvent(data: ServerWebsocketMessage): data is WsEventMessage { - return data.type === "event" && supportedEventNames.has((data as WsEventMessage).name) -} - -type Context = { message?: WsEventMessage } - -export const EventContext = React.createContext({} as Context) - -interface WsOutput { - message?: WsEventMessage -} - -function useWs(): WsOutput { - const [data, setData] = useState() - useEffect(() => { - const url = getApiUrl() - const ws = new WebSocket(`ws://${url}/ws`) - - ws.onopen = event => { - console.log("ws open", event) - } - ws.onclose = event => { - // TODO - console.log("ws close", event) - } - ws.onmessage = msg => { - const parsedMsg = JSON.parse(msg.data) as ServerWebsocketMessage - - // TODO - if (parsedMsg.type === "error") { - console.error(parsedMsg) - } - - if (isSupportedEvent(parsedMsg)) { - console.log(parsedMsg) - setData({ message: parsedMsg }) - } - } - return function cleanUp() { - console.log("ws cleanup") - ws.close() - } - }, []) - - const message = data ? data.message : undefined - return { message } -} - -export const EventProvider: React.FC = ({ children }) => { - const { message } = useWs() - - return ( - - {children} - - ) -} diff --git a/dashboard/src/contexts/README.md b/dashboard/src/contexts/README.md new file mode 100644 index 0000000000..c77e25f19d --- /dev/null +++ b/dashboard/src/contexts/README.md @@ -0,0 +1,27 @@ +# Contexts + +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. + +This way, components down the tree can access the state and the actions 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. + +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. + +### 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 new file mode 100644 index 0000000000..23b86c0bb0 --- /dev/null +++ b/dashboard/src/contexts/api-handlers.ts @@ -0,0 +1,284 @@ +/* + * 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].didFetch) { + return + } + + dispatch({ requestKey, type: "fetchStart" }) + let res: ConfigDump + try { + res = await fetchConfig() + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + const processedStore = processConfig(store, res) + dispatch({ store: processedStore, type: "fetchSuccess", requestKey }) +} + +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, + } + } + } + + const processedStore = produce(store, storeDraft => { + storeDraft.entities.modules = modules + storeDraft.entities.services = services + storeDraft.entities.tests = tests + storeDraft.entities.tasks = tasks + storeDraft.projectRoot = config.projectRoot + }) + + return processedStore +} + +interface LoadLogsHandlerParams extends LoadHandlerParams, FetchLogsParams { } + +export async function loadLogsHandler({ serviceNames, store, dispatch, force = false }: LoadLogsHandlerParams) { + const requestKey = "fetchLogs" + + if ((!force && store.requestStates[requestKey].didFetch) || !serviceNames.length) { + return + } + dispatch({ requestKey, type: "fetchStart" }) + + let res: ServiceLogEntry[] + try { + res = await fetchLogs({ serviceNames }) + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + dispatch({ store: processLogs(store, res), type: "fetchSuccess", requestKey }) +} + +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].didFetch) { + return + } + + dispatch({ requestKey, type: "fetchStart" }) + + let res: StatusCommandResult + try { + res = await fetchStatus() + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + dispatch({ store: processStatus(store, res), type: "fetchSuccess", requestKey }) +} + +function processStatus(store: Store, status: StatusCommandResult) { + const processedStore = 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 + }) + + return processedStore +} + +interface LoadTaskResultHandlerParams extends LoadHandlerParams, FetchTaskResultParams { } + +export async function loadTaskResultHandler( + { store, dispatch, force = false, ...fetchParams }: LoadTaskResultHandlerParams, +) { + const requestKey = "fetchTaskResult" + + if (!force && store.requestStates[requestKey].didFetch) { + return + } + + dispatch({ requestKey, type: "fetchStart" }) + + let res: TaskResultOutput + try { + res = await fetchTaskResult(fetchParams) + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + dispatch({ store: processTaskResult(store, res), type: "fetchSuccess", requestKey }) +} + +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].didFetch) { + return + } + + dispatch({ requestKey, type: "fetchStart" }) + + let res: TestResultOutput + try { + res = await fetchTestResult(fetchParams) + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + dispatch({ store: processTestResult(store, res), type: "fetchSuccess", requestKey }) +} + +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].didFetch) { + return + } + + dispatch({ requestKey, type: "fetchStart" }) + + let res: GraphOutput + try { + res = await fetchGraph() + } catch (error) { + dispatch({ requestKey, type: "fetchFailure", error }) + return + } + + dispatch({ store: processGraph(store, res), type: "fetchSuccess", requestKey }) +} + +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 new file mode 100644 index 0000000000..7ab8b7c4e2 --- /dev/null +++ b/dashboard/src/contexts/api.tsx @@ -0,0 +1,298 @@ +/* + * 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 { useReducer, useEffect, useContext } from "react" +import React from "react" +import produce from "immer" +import { merge } from "lodash" +import { AxiosError } from "axios" + +import { ServiceLogEntry } from "garden-service/build/src/types/plugin/service/getServiceLogs" +import { GraphOutput } from "garden-service/build/src/commands/get/get-graph" +import { ServiceStatus } from "garden-service/build/src/types/service" +import { ModuleConfig } from "garden-service/build/src/config/module" +import { PickFromUnion } from "garden-service/build/src/util/util" +import { ServiceConfig } from "garden-service/build/src/config/service" +import { RunStatus } from "garden-service/build/src/commands/get/get-status" +import { TaskConfig } from "garden-service/build/src/config/task" +import { TaskResultOutput } from "garden-service/build/src/commands/get/get-task-result" +import { TestResultOutput } from "garden-service/build/src/commands/get/get-test-result" +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" + +export type SupportedEventName = PickFromUnion + +export const supportedEventNames: Set = new Set([ + "taskPending", + "taskProcessing", + "taskComplete", + "taskGraphComplete", + "taskError", + "taskCancelled", +]) + +export type TaskState = PickFromUnion + +export interface Test { + config: TestConfig, + status: RunStatus, + result: TestResultOutput, + taskState: TaskState, // State of the test task for the module +} + +export interface Task { + config: TaskConfig, + status: RunStatus, + result: TaskResultOutput, + taskState: TaskState, // State of the task task for the module +} + +export type Module = Pick & { + services: string[], + tasks: string[], + tests: string[], + taskState: TaskState, // State of the build task for the module +} + +export interface Service { + config: ServiceConfig, + status: ServiceStatus, + taskState: TaskState, // State of the deploy task for the service +} + +interface RequestState { + loading: boolean, + didFetch: boolean + error?: AxiosError, +} + +/** + * The "global" data store + */ +export interface Store { + projectRoot: string, + entities: { + modules: { [moduleName: string]: Module } + services: { [serviceName: string]: Service } + tasks: { [taskName: string]: Task } + tests: { [testKey: string]: Test } + logs: { [serviceName: string]: ServiceLogEntry[] } + graph: GraphOutput, + providers: EnvironmentStatusMap, + }, + requestStates: { + fetchConfig: RequestState + fetchStatus: RequestState + fetchGraph: RequestState, + fetchLogs: RequestState, + fetchTestResult: RequestState, + fetchTaskResult: RequestState, + fetchTaskStates: RequestState, // represents stack graph web sockets connection + }, +} + +type RequestKey = keyof Store["requestStates"] +const requestKeys: RequestKey[] = [ + "fetchConfig", + "fetchStatus", + "fetchLogs", + "fetchTestResult", + "fetchTaskResult", + "fetchGraph", + "fetchTaskStates", +] + +interface ActionBase { + type: "fetchStart" | "fetchSuccess" | "fetchFailure" | "wsMessageReceived" +} + +interface ActionStart extends ActionBase { + requestKey: RequestKey + type: "fetchStart" +} + +interface ActionSuccess extends ActionBase { + requestKey: RequestKey + type: "fetchSuccess" + store: Store +} + +interface ActionError extends ActionBase { + requestKey: RequestKey + type: "fetchFailure" + error: AxiosError +} + +interface WsMessageReceived extends ActionBase { + type: "wsMessageReceived" + store: Store +} + +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, didFetch: false } + return acc +}, {} as { [K in RequestKey]: RequestState }) + +const initialState: Store = { + projectRoot: "", + entities: { + modules: {}, + services: {}, + tasks: {}, + tests: {}, + logs: {}, + graph: { nodes: [], relationships: [] }, + providers: {}, + }, + requestStates: initialRequestState, +} + +/** + * 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 + + switch (action.type) { + case "fetchStart": + nextStore = produce(store, storeDraft => { + storeDraft.requestStates[action.requestKey].loading = true + }) + break + case "fetchSuccess": + nextStore = produce(merge(store, action.store), storeDraft => { + storeDraft.requestStates[action.requestKey].loading = false + storeDraft.requestStates[action.requestKey].didFetch = 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].didFetch = true + }) + break + case "wsMessageReceived": + nextStore = { ...merge(store, action.store) } + 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({ name, store, dispatch, ...params }), + loadTestResult: async (params: LoadTestResultParams) => loadTestResultHandler({ store, dispatch, ...params }), + loadGraph: async (params: LoadActionParams = {}) => loadGraphHandler({ store, dispatch, ...params }), + } + + return actions +} + +type Context = { + store: Store; + actions: Actions; +} + +// Type cast the initial value to avoid having to check whether the context exists in every context consumer. +// Context is only undefined if the provider is missing which we assume is not the case. +const Context = React.createContext({} as Context) + +/** + * Returns the store and load actions via the Context + */ +export const useApi = () => useContext(Context) + +/** + * A Provider component that holds all data received from the garden-service API and websocket connections. + * The store and actions are accessed from components via the `useApi` function. + */ +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 + // if the connection breaks. + useEffect(() => { + return initWebSocket(store, dispatch) + }, []) + + return ( + + {children} + + ) +} diff --git a/dashboard/src/context/ui.tsx b/dashboard/src/contexts/ui.tsx similarity index 82% rename from dashboard/src/context/ui.tsx rename to dashboard/src/contexts/ui.tsx index 75e95eb92f..2ff0ff2786 100644 --- a/dashboard/src/context/ui.tsx +++ b/dashboard/src/contexts/ui.tsx @@ -6,9 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useState } from "react" +import React, { useState, useContext } from "react" import { ServiceIngress } from "garden-service/build/src/types/service" import { RenderedNodeType } from "garden-service/build/src/config-graph" +import { PickFromUnion } from "garden-service/build/src/util/util" interface UiState { isSidebarOpen: boolean @@ -32,7 +33,12 @@ export type SelectEntity = (selectedEntity: SelectedEntity | null) => void export type SelectIngress = (ingress: ServiceIngress | null) => void export type OverviewSupportedFilterKeys = "modules" | "modulesInfo" | "services" | "servicesInfo" | "tasks" | "tasksInfo" | "tests" | "testsInfo" -export type StackGraphSupportedFilterKeys = Exclude +export type StackGraphSupportedFilterKeys = PickFromUnion export type EntityResultSupportedTypes = StackGraphSupportedFilterKeys | "task" export type SelectedEntity = { type: EntityResultSupportedTypes, @@ -82,9 +88,8 @@ interface UiStateAndActions { actions: UiActions, } -export const UiStateContext = React.createContext({} as UiStateAndActions) - -const useUiState = () => { +// FIXME: Use useReducer instead of useState to simplify updating +const useUiStateProvider = () => { const [uiState, setState] = useState(INITIAL_UI_STATE) const toggleSidebar = () => { @@ -167,12 +172,21 @@ const useUiState = () => { } } +// Type cast the initial value to avoid having to check whether the context exists in every context consumer. +// Context is only undefined if the provider is missing which we assume is not the case. +const Context = React.createContext({} as UiStateAndActions) + +/** + * Returns the state and UI actions via the Context + */ +export const useUiState = () => useContext(Context) + export const UiStateProvider: React.FC = ({ children }) => { - const storeAndActions = useUiState() + const storeAndActions = useUiStateProvider() return ( - + {children} - + ) } diff --git a/dashboard/src/contexts/ws-handlers.ts b/dashboard/src/contexts/ws-handlers.ts new file mode 100644 index 0000000000..3473188a2f --- /dev/null +++ b/dashboard/src/contexts/ws-handlers.ts @@ -0,0 +1,100 @@ +/* + * 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 { ServerWebsocketMessage } from "garden-service/build/src/server/server" +import { Events } from "garden-service/build/src/events" + +import { + Store, + Action, + SupportedEventName, + supportedEventNames, +} from "./api" +import getApiUrl from "../api/get-api-url" + +export type WsEventMessage = ServerWebsocketMessage & { + type: "event", + name: SupportedEventName, + payload: Events[SupportedEventName], +} + +/** + * Type guard to check whether websocket message is a type supported by the Dashboard + */ +export function isSupportedEvent(data: ServerWebsocketMessage): data is WsEventMessage { + return data.type === "event" && supportedEventNames.has((data as WsEventMessage).name) +} + +export function initWebSocket(store: Store, dispatch: React.Dispatch) { + const url = getApiUrl() + const ws = new WebSocket(`ws://${url}/ws`) + ws.onopen = event => { + console.log("ws open", event) + } + ws.onclose = event => { + console.log("ws close", event) + } + ws.onmessage = msg => { + const parsedMsg = JSON.parse(msg.data) as ServerWebsocketMessage + + if (parsedMsg.type === "error") { + console.error(parsedMsg) + } + if (isSupportedEvent(parsedMsg)) { + dispatch({ store: processWebSocketMessage(store, parsedMsg), type: "wsMessageReceived" }) + } + } + return function cleanUp() { + ws.close() + } +} + +// Process the graph response and return a normalized store +function processWebSocketMessage(store: Store, message: WsEventMessage) { + const storeDraft = { ...store } + const taskType = message.payload["type"] === "task" ? "run" : message.payload["type"] // convert "task" to "run" + const taskState = message.name + const entityName = message.payload["name"] + // We don't handle taskGraphComplete events + if (taskType && taskState !== "taskGraphComplete") { + storeDraft.requestStates.fetchTaskStates.loading = true + switch (taskType) { + case "publish": + break + case "deploy": + storeDraft.entities.services[entityName] = { + ...storeDraft.entities.services[entityName], + taskState, + } + break + case "build": + storeDraft.entities.modules[entityName] = { + ...store.entities.modules[entityName], + taskState, + } + break + case "run": + storeDraft.entities.tasks[entityName] = { + ...store.entities.tasks[entityName], + taskState, + } + break + case "test": + storeDraft.entities.tests[entityName] = { + ...store.entities.tests[entityName], taskState, + } + break + } + } + + if (taskState === "taskGraphComplete") { // add to requestState graph whenever its taskGraphComplete + storeDraft.requestStates.fetchTaskStates.loading = false + } + + return storeDraft +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index 297d03cc75..fca6c05e82 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "skipLibCheck": false, + "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, diff --git a/dashboard/tslint.json b/dashboard/tslint.json index 78fb54be0e..8e16434d6a 100644 --- a/dashboard/tslint.json +++ b/dashboard/tslint.json @@ -7,6 +7,8 @@ "node_modules/tslint-microsoft-contrib" ], "rules": { + // We need to use floating promises within React hooks + "no-floating-promises": false, // Override tslint-react rules here "jsx-no-multiline-js": false, "jsx-boolean-value": [ diff --git a/garden-service/src/events.ts b/garden-service/src/events.ts index c4dd262c13..89be4818db 100644 --- a/garden-service/src/events.ts +++ b/garden-service/src/events.ts @@ -7,9 +7,9 @@ */ import { EventEmitter2 } from "eventemitter2" -import { TaskResult } from "./task-graph" -import { ModuleVersion } from "./vcs/vcs" import { LogEntry } from "./logger/log-entry" +import { ModuleVersion } from "./vcs/vcs" +import { TaskResult } from "./task-graph" /** * This simple class serves as the central event bus for a Garden instance. Its function @@ -77,21 +77,24 @@ export interface Events { taskPending: { addedAt: Date, key: string, - version: ModuleVersion, + type: string, + name: string, }, taskProcessing: { startedAt: Date, key: string, + type: string, + name: string, version: ModuleVersion, }, + taskComplete: TaskResult + taskError: TaskResult taskCancelled: { cancelledAt: Date, type: string key: string, name: string, }, - taskComplete: TaskResult, - taskError: TaskResult, taskGraphProcessing: { startedAt: Date, }, diff --git a/garden-service/src/task-graph.ts b/garden-service/src/task-graph.ts index 4b4be65378..f84bbe8657 100644 --- a/garden-service/src/task-graph.ts +++ b/garden-service/src/task-graph.ts @@ -28,6 +28,7 @@ export interface TaskResult { name: string output?: any dependencyResults?: TaskResults + completedAt: Date error?: Error } @@ -132,7 +133,8 @@ export class TaskGraph { this.garden.events.emit("taskPending", { addedAt: new Date(), key: task.getKey(), - version: task.version, + name: task.getName(), + type: task.type, }) } else { const result = this.resultCache.get(task.getKey(), task.version.versionString) @@ -206,11 +208,13 @@ export class TaskGraph { return Bluebird.map(batch, async (node: TaskNode) => { const task = node.task + const name = task.getName() const type = node.getType() const key = node.getKey() const description = node.getDescription() - let result: TaskResult = { type, description, key: task.getKey(), name: task.getName() } + let result: TaskResult + let success = true try { this.logTask(node) @@ -226,8 +230,10 @@ export class TaskGraph { try { this.pendingKeys.delete(task.getKey()) this.garden.events.emit("taskProcessing", { + name, + type, + key, startedAt: new Date(), - key: task.getKey(), version: task.version, }) result = await node.process(dependencyResults) @@ -237,16 +243,18 @@ export class TaskGraph { this.garden.events.emit("taskComplete", result) } catch (error) { - result.error = error + success = false + result = { type, description, key, name, error, completedAt: new Date() } this.garden.events.emit("taskError", result) this.logTaskError(node, error) this.cancelDependants(node) } finally { - results[key] = result - this.resultCache.put(key, task.version.versionString, result) + // We know the result got assigned in either the try or catch clause + results[key] = result! + this.resultCache.put(key, task.version.versionString, result!) } } finally { - this.completeTask(node, !result.error) + this.completeTask(node, success) } return loop() @@ -524,6 +532,7 @@ class TaskNode { key: this.getKey(), name: this.task.getName(), description: this.getDescription(), + completedAt: new Date(), output, dependencyResults, } diff --git a/garden-service/src/util/util.ts b/garden-service/src/util/util.ts index cacf522e45..6b67311c94 100644 --- a/garden-service/src/util/util.ts +++ b/garden-service/src/util/util.ts @@ -33,7 +33,7 @@ export type HookCallback = (callback?: () => void) => void const exitHookNames: string[] = [] // For debugging/testing/inspection purposes // For creating a subset of a union type, see: https://stackoverflow.com/a/53637746 -export type Extends = U +export type PickFromUnion = U export type ValueOf = T[keyof T] export type Omit = Pick> export type Diff = T extends U ? never : T diff --git a/garden-service/test/unit/src/task-graph.ts b/garden-service/test/unit/src/task-graph.ts index 4675fd5811..538aa6d8a7 100644 --- a/garden-service/test/unit/src/task-graph.ts +++ b/garden-service/test/unit/src/task-graph.ts @@ -93,6 +93,7 @@ describe("task-graph", () => { } it("should successfully process a single task without dependencies", async () => { + const now = freezeTime() const garden = await getGarden() const graph = new TaskGraph(garden, garden.log) const task = new TestTask(garden, "a", false) @@ -105,6 +106,7 @@ describe("task-graph", () => { description: "a", key: "a", name: "a", + completedAt: now, output: { result: "result-a", dependencyResults: {}, @@ -126,9 +128,26 @@ describe("task-graph", () => { const result = await graph.process([task]) expect(garden.events.eventLog).to.eql([ - { name: "taskPending", payload: { addedAt: now, key: task.getKey(), version: task.version } }, + { + name: "taskPending", + payload: { + addedAt: now, + key: task.getKey(), + name: task.name, + type: task.type, + }, + }, { name: "taskGraphProcessing", payload: { startedAt: now } }, - { name: "taskProcessing", payload: { startedAt: now, key: task.getKey(), version: task.version } }, + { + name: "taskProcessing", + payload: { + startedAt: now, + key: task.getKey(), + name: task.name, + type: task.type, + version: task.version, + }, + }, { name: "taskComplete", payload: result["a"] }, { name: "taskGraphComplete", payload: { completedAt: now } }, ]) @@ -156,6 +175,7 @@ describe("task-graph", () => { { name: "taskComplete", payload: { + completedAt: now, dependencyResults: {}, description: "a", key: task.getKey(), type: "test", name: "a", output: { dependencyResults: {}, result: "result-a" }, }, @@ -173,17 +193,47 @@ describe("task-graph", () => { const task = new TestTask(garden, "a", false, { throwError: true }) const result = await graph.process([task]) - expect(garden.events.eventLog).to.eql([ - { name: "taskPending", payload: { addedAt: now, key: task.getKey(), version: task.version } }, + { + name: "taskPending", + payload: { + addedAt: now, + key: task.getKey(), + name: task.name, + type: task.type, + }, + }, { name: "taskGraphProcessing", payload: { startedAt: now } }, - { name: "taskProcessing", payload: { startedAt: now, key: task.getKey(), version: task.version } }, + { + name: "taskProcessing", + payload: { + startedAt: now, + key: task.getKey(), + name: task.name, + type: task.type, + version: task.version, + }, + }, { name: "taskError", payload: result["a"] }, { name: "taskGraphComplete", payload: { completedAt: now } }, ]) }) + it("should have error property inside taskError event when failing a task", async () => { + freezeTime() + + const garden = await getGarden() + const graph = new TaskGraph(garden, garden.log) + const task = new TestTask(garden, "a", false, { throwError: true }) + + await graph.process([task]) + const taskError = garden.events.eventLog.find(obj => obj.name === "taskError") + + expect(taskError && taskError.payload["error"]).to.exist + }) + it("should process multiple tasks in dependency order", async () => { + const now = freezeTime() const garden = await getGarden() const graph = new TaskGraph(garden, garden.log) @@ -247,6 +297,7 @@ describe("task-graph", () => { description: "a.a1", key: "a", name: "a", + completedAt: now, output: { result: "result-a.a1", dependencyResults: {}, @@ -258,6 +309,7 @@ describe("task-graph", () => { key: "b", name: "b", description: "b.b1", + completedAt: now, output: { result: "result-b.b1", dependencyResults: { a: resultA }, @@ -269,6 +321,7 @@ describe("task-graph", () => { description: "c.c1", key: "c", name: "c", + completedAt: now, output: { result: "result-c.c1", dependencyResults: { b: resultB }, @@ -285,6 +338,7 @@ describe("task-graph", () => { description: "d.d1", key: "d", name: "d", + completedAt: now, output: { result: "result-d.d1", dependencyResults: { @@ -359,6 +413,7 @@ describe("task-graph", () => { }) it("should recursively cancel a task's dependants when it throws an error", async () => { + const now = freezeTime() const garden = await getGarden() const graph = new TaskGraph(garden, garden.log) @@ -387,6 +442,7 @@ describe("task-graph", () => { description: "a", key: "a", name: "a", + completedAt: now, output: { result: "result-a", dependencyResults: {}, @@ -405,19 +461,19 @@ describe("task-graph", () => { expect(results.b).to.have.property("error") expect(resultOrder).to.eql(["a", "b"]) expect(filteredEventLog).to.eql([ - { name: "taskPending", payload: { key: "a" } }, - { name: "taskPending", payload: { key: "b" } }, - { name: "taskPending", payload: { key: "c" } }, - { name: "taskPending", payload: { key: "d" } }, + { name: "taskPending", payload: { key: "a", name: "a", type: "test" } }, + { name: "taskPending", payload: { key: "b", name: "b", type: "test" } }, + { name: "taskPending", payload: { key: "c", name: "c", type: "test" } }, + { name: "taskPending", payload: { key: "d", name: "d", type: "test" } }, { name: "taskGraphProcessing", payload: {} }, - { name: "taskProcessing", payload: { key: "a" } }, + { name: "taskProcessing", payload: { key: "a", name: "a", type: "test" } }, { name: "taskComplete", payload: { dependencyResults: {}, description: "a", key: "a", name: "a", output: { dependencyResults: {}, result: "result-a" }, type: "test", }, }, - { name: "taskProcessing", payload: { key: "b" } }, + { name: "taskProcessing", payload: { key: "b", name: "b", type: "test" } }, { name: "taskError", payload: { description: "b", key: "b", name: "b", type: "test" } }, { name: "taskCancelled", payload: { key: "c", name: "c", type: "test" } }, { name: "taskCancelled", payload: { key: "d", name: "d", type: "test" } },