diff --git a/dashboard/src/api/actions.ts b/dashboard/src/api/actions.ts index b1b7bd429f..cb58bc36fd 100644 --- a/dashboard/src/api/actions.ts +++ b/dashboard/src/api/actions.ts @@ -11,9 +11,9 @@ 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 { GetTaskResultCommandResult } 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 { GetTestResultCommandResult } from "garden-service/build/src/commands/get/get-test-result" import { GraphOutput } from "garden-service/build/src/commands/get/get-graph" import { Entities, @@ -33,6 +33,7 @@ import { FetchTaskResultParams, FetchTestResultParams, } from "./api" +import { getTestKey } from "../util/helpers" /** * This file contains the API action functions. @@ -86,7 +87,8 @@ function processConfig(entities: Entities, config: ConfigDump) { } } for (const testConfig of cfg.testConfigs) { - const testKey = `${cfg.name}.${testConfig.name}` + // Test names are not unique so we store the data under a unique test key + const testKey = getTestKey({ testName: testConfig.name, moduleName: cfg.name }) tests[testKey] = { ...tests[testKey], config: testConfig, @@ -184,7 +186,7 @@ export async function loadTaskResult({ dispatch, ...fetchParams }: LoadTaskResul dispatch({ requestKey, type: "fetchStart" }) - let res: TaskResultOutput + let res: GetTaskResultCommandResult try { res = await fetchTaskResult(fetchParams) } catch (error) { @@ -197,11 +199,14 @@ export async function loadTaskResult({ dispatch, ...fetchParams }: LoadTaskResul dispatch({ type: "fetchSuccess", requestKey, processResults }) } -function processTaskResult(entities: Entities, result: TaskResultOutput) { +function processTaskResult(entities: Entities, result: GetTaskResultCommandResult) { return produce(entities, draft => { - draft.tasks = draft.tasks || {} - draft.tasks[result.name] = draft.tasks[result.name] || {} - draft.tasks[result.name].result = result + if (result) { + draft.tasks[result.taskName] = { + ...draft.tasks[result.taskName], + result, + } + } }) } @@ -214,7 +219,7 @@ export async function loadTestResult({ dispatch, ...fetchParams }: LoadTestResul dispatch({ requestKey, type: "fetchStart" }) - let res: TestResultOutput + let res: GetTestResultCommandResult try { res = await fetchTestResult(fetchParams) } catch (error) { @@ -227,11 +232,16 @@ export async function loadTestResult({ dispatch, ...fetchParams }: LoadTestResul dispatch({ type: "fetchSuccess", requestKey, processResults }) } -function processTestResult(entities: Entities, result: TestResultOutput) { +function processTestResult(entities: Entities, result: GetTestResultCommandResult) { return produce(entities, draft => { - draft.tests = draft.tests || {} - draft.tests[result.name] = draft.tests[result.name] || {} - draft.tests[result.name].result = result + if (result) { + // Test names are not unique so we store the data under a unique test key + const testKey = getTestKey({ testName: result.testName, moduleName: result.moduleName }) + draft.tests[testKey] = { + ...draft.tests[testKey], + result, + } + } }) } diff --git a/dashboard/src/api/api.ts b/dashboard/src/api/api.ts index f202a1ac57..5b602ddcb0 100644 --- a/dashboard/src/api/api.ts +++ b/dashboard/src/api/api.ts @@ -9,8 +9,8 @@ import axios from "axios" import { GraphOutput } from "garden-service/build/src/commands/get/get-graph" -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 { GetTaskResultCommandResult } from "garden-service/build/src/commands/get/get-task-result" +import { GetTestResultCommandResult } from "garden-service/build/src/commands/get/get-test-result" 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" @@ -49,7 +49,7 @@ export interface FetchTaskResultParams { } export async function fetchTaskResult(params: FetchTaskResultParams) { - return apiPost("get.task-result", params) + return apiPost("get.task-result", params) } export interface FetchTestResultParams { @@ -58,7 +58,7 @@ export interface FetchTestResultParams { } export async function fetchTestResult({ name, moduleName }: FetchTestResultParams) { - return apiPost("get.test-result", { name, module: moduleName }) + return apiPost("get.test-result", { name, module: moduleName }) } async function apiPost(command: string, parameters: {} = {}): Promise { @@ -73,7 +73,7 @@ async function apiPost(command: string, parameters: {} = {}): Promise { throw res.data.errors } - if (!res.data.result) { + if (res.data.result === undefined) { throw new Error("Empty response from server") } diff --git a/dashboard/src/api/ws.ts b/dashboard/src/api/ws.ts index 7fe24da764..655d23b036 100644 --- a/dashboard/src/api/ws.ts +++ b/dashboard/src/api/ws.ts @@ -14,6 +14,8 @@ import { Action, SupportedEventName, supportedEventNames, + TaskState, + taskStates, } from "../contexts/api" import getApiUrl from "./get-api-url" import produce from "immer" @@ -24,11 +26,25 @@ export type WsEventMessage = ServerWebsocketMessage & { payload: Events[SupportedEventName], } +interface TaskStateChangeEventMessage { + type: "event" + name: TaskState + payload: Events[TaskState] +} + /** * 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 isSupportedEvent(msg: ServerWebsocketMessage): msg is WsEventMessage { + return msg.type === "event" && supportedEventNames.has((msg as WsEventMessage).name) +} + +/** + * Type guard to check whether the websocket event is for a task state change that is handled + * by the Dashboard. + */ +export function isTaskStateChangeEvent(msg: WsEventMessage): msg is TaskStateChangeEventMessage { + return taskStates.includes(msg.name) } export function initWebSocket(dispatch: React.Dispatch) { @@ -58,14 +74,14 @@ export function initWebSocket(dispatch: React.Dispatch) { // Process the graph response and return a normalized store function processWebSocketMessage(store: Entities, message: WsEventMessage) { - const taskType = message.payload["type"] === "task" ? "run" : message.payload["type"] // convert "task" to "run" - const taskState = message.name - const entityName = message.payload["name"] return produce(store, draft => { - // We don't handle taskGraphComplete events - if (taskType && taskState !== "taskGraphComplete") { + if (isTaskStateChangeEvent(message)) { + const taskState = message.name + const payload = message.payload + const entityName = payload.name + draft.project.taskGraphProcessing = true - switch (taskType) { + switch (payload.type) { case "publish": break case "deploy": @@ -87,6 +103,10 @@ function processWebSocketMessage(store: Entities, message: WsEventMessage) { } break case "test": + // Note that the task payload name for tests has the same format that we use in the + // store. So there's no need to use getTestKey here. + // FIXME: We need to make this more robust, although it will resolve itself when we implement + // https://github.com/garden-io/garden/issues/1177. draft.tests[entityName] = { ...store.tests[entityName], taskState, @@ -95,8 +115,7 @@ function processWebSocketMessage(store: Entities, message: WsEventMessage) { } } - // add to requestState graph whenever its taskGraphComplete - if (taskState === "taskGraphComplete") { + if (message.name === "taskGraphComplete") { draft.project.taskGraphProcessing = false } }) diff --git a/dashboard/src/containers/entity-result.tsx b/dashboard/src/containers/entity-result.tsx index b9b845e039..b00df61b30 100644 --- a/dashboard/src/containers/entity-result.tsx +++ b/dashboard/src/containers/entity-result.tsx @@ -8,13 +8,12 @@ import React, { useEffect } from "react" import { useApi } from "../contexts/api" -import { getDuration } from "../util/helpers" +import { getDuration, getTestKey } 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 "../contexts/ui" import { loadTestResult, loadTaskResult } from "../api/actions" +import { RunResult } from "garden-service/build/src/types/plugin/base" const ErrorMsg = ({ error, type }) => ( @@ -22,7 +21,7 @@ const ErrorMsg = ({ error, type }) => ( ) -function prepareData(data: TestResultOutput | TaskResultOutput) { +function prepareData(data: RunResult) { const startedAt = data.startedAt const completedAt = data.completedAt const duration = @@ -30,7 +29,7 @@ function prepareData(data: TestResultOutput | TaskResultOutput) { completedAt && getDuration(startedAt, completedAt) - const output = data.output + const output = data.log return { duration, startedAt, completedAt, output } } @@ -50,11 +49,13 @@ export default ({ name, moduleName, type, onClose }: Props) => { const { dispatch, store: { - entities: { tasks, tests }, + entities, requestStates, }, } = useApi() + const { tasks, tests } = entities + const loadResults = () => { if (type === "test") { loadTestResult({ dispatch, name, moduleName }) @@ -66,7 +67,9 @@ export default ({ name, moduleName, type, onClose }: Props) => { useEffect(loadResults, [name, moduleName]) if (type === "test") { - const testResult = tests && tests[name] && tests[name].result + const testKey = getTestKey({ moduleName, testName: name }) + + const testResult = tests && tests[testKey] && tests[testKey].result if (requestStates.testResult.error) { return diff --git a/dashboard/src/containers/graph.tsx b/dashboard/src/containers/graph.tsx index e302933f08..26607e67c8 100644 --- a/dashboard/src/containers/graph.tsx +++ b/dashboard/src/containers/graph.tsx @@ -24,6 +24,7 @@ import { RenderedNode } from "garden-service/build/src/config-graph" import { GraphOutput } from "garden-service/build/src/commands/get/get-graph" import { loadGraph } from "../api/actions" import { useConfig } from "../util/hooks" +import { getTestKey } from "../util/helpers" const Wrapper = styled.div` padding-left: .75rem; @@ -40,11 +41,13 @@ export default () => { const { dispatch, store: { - entities: { project, modules, services, tests, tasks, graph }, + entities, requestStates, }, } = useApi() + const { project, modules, services, tests, tasks, graph } = entities + const { actions: { selectGraphNode, stackGraphToggleItemsView, clearGraphNodeSelection }, state: { selectedGraphNode, isSidebarOpen, stackGraph: { filters } }, @@ -83,7 +86,8 @@ export default () => { taskState = (tasks[node.name] && tasks[node.name].taskState) || taskState break case "test": - taskState = (tests[node.name] && tests[node.name].taskState) || taskState + const testKey = getTestKey({ testName: node.name, moduleName: node.moduleName }) + taskState = (tests[testKey] && tests[testKey].taskState) || taskState break } return { ...node, status: taskState } diff --git a/dashboard/src/contexts/api.tsx b/dashboard/src/contexts/api.tsx index dc50369d35..91b94a5098 100644 --- a/dashboard/src/contexts/api.tsx +++ b/dashboard/src/contexts/api.tsx @@ -19,8 +19,8 @@ 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 { GetTaskResultCommandResult } from "garden-service/build/src/commands/get/get-task-result" +import { GetTestResultCommandResult } 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" @@ -52,17 +52,25 @@ export type TaskState = PickFromUnion +export const taskStates = [ + "taskComplete", + "taskError", + "taskPending", + "taskProcessing", + "taskCancelled", +] + export interface Test { config: TestConfig, status: RunStatus, - result: TestResultOutput, + result: GetTestResultCommandResult, taskState: TaskState, // State of the test task for the module } export interface Task { config: TaskConfig, status: RunStatus, - result: TaskResultOutput, + result: GetTaskResultCommandResult, taskState: TaskState, // State of the task task for the module } diff --git a/dashboard/src/util/helpers.ts b/dashboard/src/util/helpers.ts index 1f36c04d29..04686cafff 100644 --- a/dashboard/src/util/helpers.ts +++ b/dashboard/src/util/helpers.ts @@ -68,3 +68,10 @@ export function getLinkUrl(ingress: ServiceIngress) { pathname: ingress.path, })) } + +/** + * Test names are not unique so we construct a unique key from the module name and the test name. + */ +export function getTestKey({ testName, moduleName }: { testName: string, moduleName: string }) { + return `${moduleName}.${testName}` +} diff --git a/garden-service/src/commands/get/get-task-result.ts b/garden-service/src/commands/get/get-task-result.ts index 32ac0715a9..bf17fc3ac4 100644 --- a/garden-service/src/commands/get/get-task-result.ts +++ b/garden-service/src/commands/get/get-task-result.ts @@ -18,15 +18,6 @@ import { getTaskVersion } from "../../tasks/task" import { RunTaskResult } from "../../types/plugin/task/runTask" import chalk from "chalk" -export interface TaskResultOutput { - name: string - module: string | null - version: string | null - output: string | null - startedAt: Date | null - completedAt: Date | null -} - const getTaskResultArgs = { name: new StringParameter({ help: "The name of the task", @@ -36,13 +27,17 @@ const getTaskResultArgs = { type Args = typeof getTaskResultArgs +export type GetTaskResultCommandResult = RunTaskResult | null + export class GetTaskResultCommand extends Command { name = "task-result" help = "Outputs the latest execution result of a provided task." arguments = getTaskResultArgs - async action({ garden, log, headerLog, args }: CommandParams): Promise> { + async action( + { garden, log, headerLog, args }: CommandParams, + ): Promise> { const taskName = args.name const graph: ConfigGraph = await garden.getConfigGraph() @@ -50,7 +45,7 @@ export class GetTaskResultCommand extends Command { const actions = await garden.getActionHelper() - const taskResult: RunTaskResult | null = await actions.getTaskResult( + const result = await actions.getTaskResult( { log, task, @@ -64,31 +59,14 @@ export class GetTaskResultCommand extends Command { "rocket", ) - if (taskResult !== null) { - const output: TaskResultOutput = { - name: taskResult.taskName, - module: taskResult.moduleName, - version: taskResult.version, - output: taskResult.output || null, - startedAt: taskResult.startedAt, - completedAt: taskResult.completedAt, - } - - log.info({ data: taskResult }) - return { result: output } - } else { + if (result === null) { log.info( `Could not find results for task '${taskName}'`, ) - const output: TaskResultOutput = { - name: taskName, - module: null, - version: null, - output: null, - startedAt: null, - completedAt: null, - } - return { result: output } + } else { + log.info({ data: result }) } + + return { result } } } diff --git a/garden-service/src/commands/get/get-test-result.ts b/garden-service/src/commands/get/get-test-result.ts index b747ea13f4..88152028bc 100644 --- a/garden-service/src/commands/get/get-test-result.ts +++ b/garden-service/src/commands/get/get-test-result.ts @@ -19,15 +19,6 @@ import { findByName, getNames } from "../../util/util" import { printHeader } from "../../logger/util" import chalk from "chalk" -export interface TestResultOutput { - module: string - name: string - version: string | null - output: string | null - startedAt: Date | null - completedAt: Date | null -} - const getTestResultArgs = { module: new StringParameter({ help: "Module name of where the test runs.", @@ -39,6 +30,8 @@ const getTestResultArgs = { }), } +export type GetTestResultCommandResult = TestResult | null + type Args = typeof getTestResultArgs export class GetTestResultCommand extends Command { @@ -47,7 +40,9 @@ export class GetTestResultCommand extends Command { arguments = getTestResultArgs - async action({ garden, log, headerLog, args }: CommandParams): Promise> { + async action( + { garden, log, headerLog, args }: CommandParams, + ): Promise> { const testName = args.name const moduleName = args.module @@ -79,39 +74,19 @@ export class GetTestResultCommand extends Command { const testVersion = await getTestVersion(garden, graph, module, testConfig) - const testResult: TestResult | null = await actions.getTestResult({ + const result = await actions.getTestResult({ log, testName, module, testVersion, }) - if (testResult !== null) { - const output: TestResultOutput = { - name: testResult.testName, - module: testResult.moduleName, - startedAt: testResult.startedAt, - completedAt: testResult.completedAt, - version: testResult.version, - output: testResult.output || null, - } - - log.info({ data: testResult }) - return { result: output } + if (result === null) { + log.info(`Could not find results for test '${testName}'`) } else { - const errorMessage = `Could not find results for test '${testName}'` - - log.info(errorMessage) - - const output: TestResultOutput = { - name: testName, - module: moduleName, - version: null, - output: null, - startedAt: null, - completedAt: null, - } - return { result: output } + log.info({ data: result }) } + + return { result } } } diff --git a/garden-service/test/unit/src/commands/get/get-task-result.ts b/garden-service/test/unit/src/commands/get/get-task-result.ts index a808f6e539..9395a7c4ff 100644 --- a/garden-service/test/unit/src/commands/get/get-task-result.ts +++ b/garden-service/test/unit/src/commands/get/get-task-result.ts @@ -1,18 +1,59 @@ -import { dataDir, makeTestGarden, expectError, withDefaultGlobalOpts } from "../../../../helpers" -import { resolve } from "path" +import { join } from "path" +import { + dataDir, + expectError, + withDefaultGlobalOpts, + configureTestModule, +} from "../../../../helpers" import { GetTaskResultCommand } from "../../../../../src/commands/get/get-task-result" import { expect } from "chai" -import { pick } from "lodash" +import { PluginFactory } from "../../../../../src/types/plugin/plugin" +import { LogEntry } from "../../../../../src/logger/log-entry" +import { Garden } from "../../../../../src/garden" +import { GetTaskResultParams } from "../../../../../src/types/plugin/task/getTaskResult" + +const now = new Date() + +const taskResults = { + "task-a": { + moduleName: "module-a", + taskName: "task-a", + command: ["foo"], + completedAt: now, + log: "bla bla", + outputs: { + log: "bla bla", + }, + success: true, + startedAt: now, + version: "1234", + }, + "task-c": null, +} + +const testPlugin: PluginFactory = async () => ({ + moduleActions: { + test: { + configure: configureTestModule, + getTaskResult: async (params: GetTaskResultParams) => taskResults[params.task.name], + }, + }, +}) describe("GetTaskResultCommand", () => { - it("should throw error if task not found", async () => { - const name = "imaginary-task" + let garden: Garden + let log: LogEntry + const command = new GetTaskResultCommand() - const garden = await makeTestGarden( - resolve(dataDir, "test-project-dependants"), - ) - const log = garden.log - const command = new GetTaskResultCommand() + before(async () => { + const plugins = { "test-plugin": testPlugin } + const projectRootB = join(dataDir, "test-project-b") + garden = await Garden.factory(projectRootB, { plugins }) + log = garden.log + }) + + it("should throw error if task not found", async () => { + const name = "banana" await expectError( async () => @@ -29,11 +70,32 @@ describe("GetTaskResultCommand", () => { }) it("should return the task result", async () => { - const name = "task-c" + const name = "task-a" + + const res = await command.action({ + garden, + log, + footerLog: log, + headerLog: log, + args: { name }, + opts: withDefaultGlobalOpts({}), + }) + + expect(res.result).to.be.eql({ + moduleName: "module-a", + taskName: "task-a", + command: ["foo"], + completedAt: now, + log: "bla bla", + outputs: { log: "bla bla" }, + success: true, + startedAt: now, + version: "1234", + }) + }) - const garden = await makeTestGarden(resolve(dataDir, "test-project-a")) - const log = garden.log - const command = new GetTaskResultCommand() + it("should return result null if task result does not exist", async () => { + const name = "task-c" const res = await command.action({ garden, @@ -44,6 +106,7 @@ describe("GetTaskResultCommand", () => { opts: withDefaultGlobalOpts({}), }) - expect(pick(res.result, ["output", "name"])).to.eql({ output: null, name }) + expect(res.result).to.be.null }) + }) diff --git a/garden-service/test/unit/src/commands/get/get-test-result.ts b/garden-service/test/unit/src/commands/get/get-test-result.ts index 9f3259ea72..0ab62b7d6e 100644 --- a/garden-service/test/unit/src/commands/get/get-test-result.ts +++ b/garden-service/test/unit/src/commands/get/get-test-result.ts @@ -1,19 +1,58 @@ -import { dataDir, makeTestGarden, expectError, withDefaultGlobalOpts } from "../../../../helpers" -import { resolve } from "path" +import { + expectError, + withDefaultGlobalOpts, + configureTestModule, + makeTestGardenA, +} from "../../../../helpers" import { GetTestResultCommand } from "../../../../../src/commands/get/get-test-result" import { expect } from "chai" -import { pick } from "lodash" +import { PluginFactory } from "../../../../../src/types/plugin/plugin" +import { GetTestResultParams } from "../../../../../src/types/plugin/module/getTestResult" +import { Garden } from "../../../../../src/garden" +import { LogEntry } from "../../../../../src/logger/log-entry" + +const now = new Date() + +const testResults = { + unit: { + moduleName: "module-a", + command: [], + completedAt: now, + log: "bla bla", + outputs: { + log: "bla bla", + }, + success: true, + startedAt: now, + testName: "unit", + version: "1234", + }, + integration: null, +} + +const testPlugin: PluginFactory = async () => ({ + moduleActions: { + test: { + configure: configureTestModule, + getTestResult: async (params: GetTestResultParams) => testResults[params.testName], + }, + }, +}) describe("GetTestResultCommand", () => { - it("should throw error if test not found", async () => { - const name = "test-run" - const module = "test-module" + let garden: Garden + let log: LogEntry + const command = new GetTestResultCommand() + const module = "module-a" - const garden = await makeTestGarden( - resolve(dataDir, "test-project-dependants"), - ) - const log = garden.log - const command = new GetTestResultCommand() + before(async () => { + const plugins = { "test-plugin": testPlugin } + garden = await makeTestGardenA(plugins) + log = garden.log + }) + + it("should throw error if test not found", async () => { + const name = "banana" await expectError( async () => @@ -25,17 +64,12 @@ describe("GetTestResultCommand", () => { args: { name, module }, opts: withDefaultGlobalOpts({}), }), - "parameter", + "not-found", ) }) it("should return the test result", async () => { const name = "unit" - const module = "module-c" - - const garden = await makeTestGarden(resolve(dataDir, "test-project-a")) - const log = garden.log - const command = new GetTestResultCommand() const res = await command.action({ garden, @@ -46,6 +80,34 @@ describe("GetTestResultCommand", () => { opts: withDefaultGlobalOpts({}), }) - expect(pick(res.result, ["output", "name", "module"])).to.eql({ output: null, name, module }) + expect(res.result).to.eql({ + moduleName: "module-a", + command: [], + completedAt: now, + log: "bla bla", + outputs: { + log: "bla bla", + }, + success: true, + startedAt: now, + testName: "unit", + version: "1234", + }) + }) + + it("should return result null if test result does not exist", async () => { + const name = "integration" + + const res = await command.action({ + garden, + log, + footerLog: log, + headerLog: log, + args: { name, module }, + opts: withDefaultGlobalOpts({}), + }) + + expect(res.result).to.be.null }) + })