diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6f4dc26 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://fennec.localhost/", + "webRoot": "${workspaceFolder}/src", + "sourceMaps": true, + "sourceMapPathOverrides": { + "webpack:///./*": "${workspaceFolder}/src/*", + "webpack:///src/*": "${workspaceFolder}/src/*", + "webpack:///node_modules/*": "${workspaceFolder}/node_modules/*", + "webpack:///../node_modules/*": "${workspaceFolder}/node_modules/*" + }, + "showAsyncStacks": true + } + ] +} diff --git a/graphql.schema.json b/graphql.schema.json index 26fd76e..4c5da1e 100644 --- a/graphql.schema.json +++ b/graphql.schema.json @@ -636,7 +636,7 @@ "name": null, "ofType": { "kind": "ENUM", - "name": "PipelineUnits", + "name": "TaskStatuses", "ofType": null } }, @@ -689,6 +689,41 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "TaskStatuses", + "description": "任务状态", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "success", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "working", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "SCALAR", "name": "DateTime", @@ -862,41 +897,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "ENUM", - "name": "TaskStatuses", - "description": "任务状态", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "success", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "failed", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "working", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pending", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, { "kind": "OBJECT", "name": "PipelineTaskLogMessage", @@ -906,12 +906,40 @@ "name": "unit", "description": null, "args": [], + "type": { + "kind": "ENUM", + "name": "PipelineUnits", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "time", + "description": null, + "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { - "kind": "ENUM", - "name": "PipelineUnits", + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "message", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", "ofType": null } }, @@ -1169,6 +1197,80 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "listPipelineTaskByPipelineId", + "description": null, + "args": [ + { + "name": "pipelineId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PipelineTask", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "findPipelineTask", + "description": null, + "args": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PipelineTask", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, diff --git a/package-lock.json b/package-lock.json index 3bb4fbd..efa5335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fortawesome/react-fontawesome": "^0.1.14", "@tailwindcss/postcss7-compat": "^2.0.3", "@types/classnames": "^2.2.11", + "@types/ramda": "^0.27.39", "autoprefixer": "^9.8.6", "classnames": "^2.2.6", "formik": "^2.2.6", @@ -26,6 +27,7 @@ "preact-markup": "^2.1.1", "preact-render-to-string": "^5.1.4", "preact-router": "^3.2.1", + "ramda": "^0.27.1", "subscriptions-transport-ws": "^0.9.18", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.3" }, @@ -3308,6 +3310,14 @@ "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==", "dev": true }, + "node_modules/@types/ramda": { + "version": "0.27.39", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.39.tgz", + "integrity": "sha512-od24Hng0uS1NcMSPo6ZzKXN2WJxjbF7IBzQhRCtSDyIShdEJTAWXgQlyDg998aylNrXbVvQxkFJmuaLHQcfczg==", + "dependencies": { + "ts-toolbelt": "^6.15.1" + } + }, "node_modules/@types/react": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.2.tgz", @@ -18018,6 +18028,11 @@ "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=", "dev": true }, + "node_modules/ramda": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", + "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==" + }, "node_modules/randexp": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", @@ -21460,6 +21475,11 @@ "node": ">=10.0.0" } }, + "node_modules/ts-toolbelt": { + "version": "6.15.5", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz", + "integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==" + }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -27487,6 +27507,14 @@ "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==", "dev": true }, + "@types/ramda": { + "version": "0.27.39", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.39.tgz", + "integrity": "sha512-od24Hng0uS1NcMSPo6ZzKXN2WJxjbF7IBzQhRCtSDyIShdEJTAWXgQlyDg998aylNrXbVvQxkFJmuaLHQcfczg==", + "requires": { + "ts-toolbelt": "^6.15.1" + } + }, "@types/react": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.2.tgz", @@ -39881,6 +39909,11 @@ "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=", "dev": true }, + "ramda": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", + "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==" + }, "randexp": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", @@ -42797,6 +42830,11 @@ "yn": "3.1.1" } }, + "ts-toolbelt": { + "version": "6.15.5", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz", + "integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==" + }, "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", diff --git a/package.json b/package.json index 5a16a38..8c11815 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@fortawesome/react-fontawesome": "^0.1.14", "@tailwindcss/postcss7-compat": "^2.0.3", "@types/classnames": "^2.2.11", + "@types/ramda": "^0.27.39", "autoprefixer": "^9.8.6", "classnames": "^2.2.6", "formik": "^2.2.6", @@ -43,6 +44,7 @@ "preact-markup": "^2.1.1", "preact-render-to-string": "^5.1.4", "preact-router": "^3.2.1", + "ramda": "^0.27.1", "subscriptions-transport-ws": "^0.9.18", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.3" }, diff --git a/src/components/commit-actions/commit-actions.tsx b/src/components/commit-actions/commit-actions.tsx index 8c149e4..e74a922 100644 --- a/src/components/commit-actions/commit-actions.tsx +++ b/src/components/commit-actions/commit-actions.tsx @@ -21,10 +21,11 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { useLocalObservable } from 'mobx-react'; import { PipelineTask, PipelineUnits } from '../../generated/graphql'; +import { route } from 'preact-router'; const CREATE_PIPELINE_TASK = gql` mutation CreatePipelineTask($task: CreatePipelineTaskInput!) { - pipelineTask: createPipelineTask(task: $task) { + task: createPipelineTask(task: $task) { id units commit @@ -50,16 +51,19 @@ class Store { export const CommitActions = ({ pipeline, commit }: Props) => { const [createTask] = useMutation< - PipelineTask, + { task: PipelineTask }, { task: CreatePipelineTaskInput } >(CREATE_PIPELINE_TASK); const doWork = async (units: PipelineUnits[]) => { - await createTask({ + const { data } = await createTask({ variables: { task: { pipelineId: pipeline.id, commit, units } } }); + route( + `/projects/${pipeline.projectId}/pipelines/${pipeline.id}/tasks/${data?.task.id}` + ); }; const commitActionsStore = useLocalObservable(() => new CommitActionsStore()); diff --git a/src/components/pipeline-tasks/pipeline-task-details.scss b/src/components/pipeline-tasks/pipeline-task-details.scss new file mode 100644 index 0000000..0dc81f9 --- /dev/null +++ b/src/components/pipeline-tasks/pipeline-task-details.scss @@ -0,0 +1,25 @@ +.details { + @apply pt-2 flex flex-col w-full; +} + +.navContainer { + @apply bg-white p-2 text-gray-700; + @apply flex; +} +.navItem { + @apply w-full flex-auto text-center; + &:not(:first-child) { + @apply border-l border-gray-200; + } +} +.LogListOfUnit { + @apply overflow-scroll; +} +.unitLogsContainer { + @apply bg-gray-200; +} +.unitLogs { + @apply whitespace-pre-line font-mono text-sm; + @apply p-1 my-px; + @apply bg-gray-100; +} diff --git a/src/components/pipeline-tasks/pipeline-task-details.scss.d.ts b/src/components/pipeline-tasks/pipeline-task-details.scss.d.ts new file mode 100644 index 0000000..04b057b --- /dev/null +++ b/src/components/pipeline-tasks/pipeline-task-details.scss.d.ts @@ -0,0 +1,15 @@ +// This file is automatically generated from your CSS. Any edits will be overwritten. +declare namespace PipelineTaskDetailsScssNamespace { + export interface IPipelineTaskDetailsScss { + LogListOfUnit: string; + details: string; + navContainer: string; + navItem: string; + unitLogs: string; + unitLogsContainer: string; + } +} + +declare const PipelineTaskDetailsScssModule: PipelineTaskDetailsScssNamespace.IPipelineTaskDetailsScss; + +export = PipelineTaskDetailsScssModule; diff --git a/src/components/pipeline-tasks/pipeline-task-details.tsx b/src/components/pipeline-tasks/pipeline-task-details.tsx new file mode 100644 index 0000000..7591f8b --- /dev/null +++ b/src/components/pipeline-tasks/pipeline-task-details.tsx @@ -0,0 +1,145 @@ +import { gql, useSubscription, useQuery } from '@apollo/client'; +import { h } from 'preact'; +import { useMemo } from 'preact/hooks'; +import { find, propEq } from 'ramda'; +import { observer, useLocalObservable, useObserver } from 'mobx-react'; +import { makeAutoObservable } from 'mobx'; +import styles from './pipeline-task-details.scss'; +import { + PipelineTaskLogs, + TaskStatuses, + PipelineUnits, + PipelineTask, + PipelineTaskLogMessage +} from '../../generated/graphql'; +interface Props { + taskId: string; +} + +class Store { + logs: PipelineTaskLogs[] = []; + task?: PipelineTask; + constructor() { + makeAutoObservable(this); + } + + addLogsFromTask(task: PipelineTask) { + for (const log of task.logs) { + const taskLog = find(propEq('unit', log.unit), this.logs); + if (!taskLog) { + this.logs.push(log); + } else { + taskLog.logs = log.logs + taskLog.logs; + } + } + } + addLog(log: PipelineTaskLogMessage) { + const taskLog = find(propEq('unit', log.unit), this.logs); + if (!taskLog) { + this.logs.push({ + unit: (log.unit as unknown) as PipelineUnits, + status: TaskStatuses.Working, + startedAt: log.time, + logs: log.message + }); + } else { + taskLog.logs += log.message; + } + } + + setTask(task: PipelineTask) { + this.task = task; + } +} + +const FIND_PIPELINE_TASK = gql` + query FindPipelineTask($taskId: String!) { + task: findPipelineTask(id: $taskId) { + id + units + commit + logs { + unit + logs + } + } + } +`; + +const PIPELINE_TASK_LOG = gql` + subscription PipelineTaskLog($taskId: String!) { + log: pipelineTaskLog(taskId: $taskId) { + unit + time + message + } + } +`; + +export const PipelineTaskDetails = ({ taskId }: Props) => { + const { data } = useQuery<{ task: PipelineTask }, { taskId: string }>( + FIND_PIPELINE_TASK, + { variables: { taskId } } + ); + + const store = useLocalObservable(() => new Store()); + + useMemo(() => { + store.task = data?.task; + if (!data?.task.logs) { + return; + } + store.addLogsFromTask(data.task); + }, [data]); + + useSubscription< + { + log: PipelineTaskLogMessage; + }, + { taskId: string } + >(PIPELINE_TASK_LOG, { + variables: { taskId }, + onSubscriptionData: ({ subscriptionData }) => { + const log = subscriptionData.data?.log; + if (!log) { + return; + } + store.addLog(log); + } + }); + return useObserver(() => ( +
+ + + +
+ )); +}; + +const UnitList = observer(({ store }: { store: Store }) => { + if (!store.task) { + return
Loading...
; + } + + const tabs = store.task.units.map(unit => ( +
  • + {unit} +
  • + )); + + return
      {tabs}
    ; +}); + +const LogListOfUnit = observer(({ store }: { store: Store }) => { + const tabs = store.logs.map(unitLogs => ( +
  • + {unitLogs.logs} +
  • + )); + + return ( +
    +
      {tabs}
    +
    + ); +}); diff --git a/src/components/pipeline-tasks/pipeline-task-list.tsx b/src/components/pipeline-tasks/pipeline-task-list.tsx new file mode 100644 index 0000000..bb726da --- /dev/null +++ b/src/components/pipeline-tasks/pipeline-task-list.tsx @@ -0,0 +1,90 @@ +import { h } from 'preact'; +import styles from './pipeline-tasks.scss'; +import { gql, useQuery } from '@apollo/client'; +import { PipelineTask, TaskStatuses } from '../../generated/graphql'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faCheckCircle, + faClock, + faExclamationCircle, + faRunning +} from '@fortawesome/free-solid-svg-icons'; + +const LIST_TASKS = gql` + query ListTasks($pipelineId: String!) { + tasks: listPipelineTaskByPipelineId(pipelineId: $pipelineId) { + id + pipelineId + commit + units + logs { + unit + status + startedAt + endedAt + } + status + startedAt + endedAt + } + } +`; + +interface Props { + pipelineId: string; +} + +export const PipelineTaskList = ({ pipelineId }: Props) => { + const { data, loading } = useQuery< + { tasks: PipelineTask[] }, + { pipelineId: string } + >(LIST_TASKS, { + variables: { pipelineId } + }); + + const items = data?.tasks.map(item => ); + const list = loading ? ( + Loading... + ) : ( +
      {items}
    + ); + + return
    {list}
    ; +}; + +const Item = ({ task }: { task: PipelineTask }) => { + return ( +
  • + + {task.id} + {task.commit} + {task.startedAt} +
      + {task.units.map((unit, index) => ( +
    1. {unit}
    2. + ))} +
    +
    {JSON.stringify(task.logs)}
    +
  • + ); +}; + +const Status = ({ status }: { status: TaskStatuses }) => { + let icon; + switch (status) { + case TaskStatuses.Failed: + icon = faExclamationCircle; + break; + case TaskStatuses.Success: + icon = faCheckCircle; + break; + case TaskStatuses.Pending: + icon = faClock; + break; + case TaskStatuses.Working: + icon = faRunning; + break; + } + + return ; +}; diff --git a/src/components/pipeline-tasks/pipeline-tasks.scss b/src/components/pipeline-tasks/pipeline-tasks.scss new file mode 100644 index 0000000..10cdc3b --- /dev/null +++ b/src/components/pipeline-tasks/pipeline-tasks.scss @@ -0,0 +1,17 @@ +.taskList { + @apply bg-white w-full; +} + +.list { + @apply bg-gray-200 max-h-full overflow-y-auto; +} +.item { + @apply bg-white py-2 px-4 my-px; + @apply grid; +} +.units { + @apply inline text-gray-700; +} +.details { + @apply bg-red-100; +} diff --git a/src/components/pipeline-tasks/pipeline-tasks.scss.d.ts b/src/components/pipeline-tasks/pipeline-tasks.scss.d.ts new file mode 100644 index 0000000..c1daee8 --- /dev/null +++ b/src/components/pipeline-tasks/pipeline-tasks.scss.d.ts @@ -0,0 +1,14 @@ +// This file is automatically generated from your CSS. Any edits will be overwritten. +declare namespace PipelineTasksScssNamespace { + export interface IPipelineTasksScss { + details: string; + item: string; + list: string; + taskList: string; + units: string; + } +} + +declare const PipelineTasksScssModule: PipelineTasksScssNamespace.IPipelineTasksScss; + +export = PipelineTasksScssModule; diff --git a/src/generated/graphql.tsx b/src/generated/graphql.tsx index 26bf553..f033041 100644 --- a/src/generated/graphql.tsx +++ b/src/generated/graphql.tsx @@ -81,12 +81,20 @@ export type LogList = { export type PipelineTaskLogs = { __typename?: 'PipelineTaskLogs'; unit: PipelineUnits; - status: PipelineUnits; + status: TaskStatuses; startedAt?: Maybe; endedAt?: Maybe; logs: Scalars['String']; }; +/** 任务状态 */ +export enum TaskStatuses { + Success = 'success', + Failed = 'failed', + Working = 'working', + Pending = 'pending' +} + export type PipelineTask = { __typename?: 'PipelineTask'; @@ -101,17 +109,11 @@ export type PipelineTask = { endedAt?: Maybe; }; -/** 任务状态 */ -export enum TaskStatuses { - Success = 'success', - Failed = 'failed', - Working = 'working', - Pending = 'pending' -} - export type PipelineTaskLogMessage = { __typename?: 'PipelineTaskLogMessage'; - unit: PipelineUnits; + unit?: Maybe; + time: Scalars['DateTime']; + message: Scalars['String']; }; export type WorkUnitInput = { @@ -131,6 +133,8 @@ export type Query = { findProject: Project; listPipelines: Array; findPipeline: Pipeline; + listPipelineTaskByPipelineId: Array; + findPipelineTask: PipelineTask; }; @@ -148,6 +152,16 @@ export type QueryFindPipelineArgs = { id: Scalars['String']; }; + +export type QueryListPipelineTaskByPipelineIdArgs = { + pipelineId: Scalars['String']; +}; + + +export type QueryFindPipelineTaskArgs = { + id: Scalars['String']; +}; + export type Mutation = { __typename?: 'Mutation'; createProject: Project; diff --git a/src/routes/projects/project-details.tsx b/src/routes/projects/project-details.tsx index e09bc69..dd8b129 100644 --- a/src/routes/projects/project-details.tsx +++ b/src/routes/projects/project-details.tsx @@ -11,6 +11,8 @@ import { Observer, useLocalObservable } from 'mobx-react'; import Router, { RoutableProps, Route } from 'preact-router'; import { gql, useQuery } from '@apollo/client'; import { PipelineList } from '../../components/pipelines/pipeline-list'; +import { PipelineTaskList } from '../../components/pipeline-tasks/pipeline-task-list'; +import { PipelineTaskDetails } from '../../components/pipeline-tasks/pipeline-task-details'; const FIND_PROJECT = gql` query FindProject($id: String!) { @@ -86,6 +88,10 @@ export const ProjectDetails = ({ id, path }: Props) => { {(): any => ( +