This is an automated email from the ASF dual-hosted git repository. abeizn pushed a commit to branch release-v1.0 in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
commit 0a7410c583a3455e456b7eb10bdb2932ff108c93 Author: 青湛 <[email protected]> AuthorDate: Tue Mar 26 21:08:49 2024 +1300 feat: add onboard finish status (#7218) --- config-ui/src/api/pipeline/index.ts | 2 + .../components/connection-name/index.tsx} | 45 +++-- config-ui/src/plugins/components/index.ts | 1 + .../onboard}/components/index.ts | 9 +- config-ui/src/routes/onboard/components/logs.tsx | 105 ++++++++++ config-ui/src/routes/onboard/context.tsx | 14 +- config-ui/src/routes/onboard/index.tsx | 3 +- config-ui/src/routes/onboard/step-2.tsx | 2 +- config-ui/src/routes/onboard/step-3.tsx | 11 +- config-ui/src/routes/onboard/step-4.tsx | 211 ++++++++++++++++++++- 10 files changed, 360 insertions(+), 43 deletions(-) diff --git a/config-ui/src/api/pipeline/index.ts b/config-ui/src/api/pipeline/index.ts index 1ec8e025c..2d7261d57 100644 --- a/config-ui/src/api/pipeline/index.ts +++ b/config-ui/src/api/pipeline/index.ts @@ -36,3 +36,5 @@ export const rerun = (id: ID) => export const log = (id: ID) => request(`/pipelines/${id}/logging.tar.gz`); export const tasks = (id: ID) => request(`/pipelines/${id}/tasks`); + +export const subTasks = (id: ID) => request(`/pipelines/${id}/subtasks`); diff --git a/config-ui/src/routes/onboard/step-4.tsx b/config-ui/src/plugins/components/connection-name/index.tsx similarity index 61% copy from config-ui/src/routes/onboard/step-4.tsx copy to config-ui/src/plugins/components/connection-name/index.tsx index e2d51cfed..fd3a59d3a 100644 --- a/config-ui/src/routes/onboard/step-4.tsx +++ b/config-ui/src/plugins/components/connection-name/index.tsx @@ -16,38 +16,43 @@ * */ -import { SmileFilled } from '@ant-design/icons'; +import { useMemo } from 'react'; import { theme } from 'antd'; import styled from 'styled-components'; -import * as S from './styled'; +import { getPluginConfig } from '@/plugins'; -const Top = styled.div` +const Wrapper = styled.div` display: flex; align-items: center; - justify-content: center; - margin-top: 100px; - margin-bottom: 24px; - height: 70px; - - span.text { - margin-left: 8px; - font-size: 20px; + + .icon { + display: inline-flex; + margin-right: 8px; + width: 24px; + + & > svg { + width: 100%; + height: 100%; + } } `; -export const Step4 = () => { +interface Props { + plugin: string; +} + +export const ConnectionName = ({ plugin }: Props) => { + const config = useMemo(() => getPluginConfig(plugin), [plugin]); + const { - token: { green5 }, + token: { colorPrimary }, } = theme.useToken(); return ( - <> - <Top> - <SmileFilled style={{ fontSize: 36, color: green5 }} /> - <span className="text">Congratulations!You have successfully connected to your first repository!</span> - </Top> - <S.StepContent style={{ padding: 24 }}></S.StepContent> - </> + <Wrapper> + <span className="icon">{config.icon({ color: colorPrimary })}</span> + <span className="name">Manage Connections: {config.name}</span> + </Wrapper> ); }; diff --git a/config-ui/src/plugins/components/index.ts b/config-ui/src/plugins/components/index.ts index 2f4049cab..7b4e89a9e 100644 --- a/config-ui/src/plugins/components/index.ts +++ b/config-ui/src/plugins/components/index.ts @@ -18,6 +18,7 @@ export * from './connection-form'; export * from './connection-list'; +export * from './connection-name'; export * from './connection-select'; export * from './connection-status'; export * from './data-scope-remote'; diff --git a/config-ui/src/plugins/components/index.ts b/config-ui/src/routes/onboard/components/index.ts similarity index 73% copy from config-ui/src/plugins/components/index.ts copy to config-ui/src/routes/onboard/components/index.ts index 2f4049cab..49ecf41b7 100644 --- a/config-ui/src/plugins/components/index.ts +++ b/config-ui/src/routes/onboard/components/index.ts @@ -16,11 +16,4 @@ * */ -export * from './connection-form'; -export * from './connection-list'; -export * from './connection-select'; -export * from './connection-status'; -export * from './data-scope-remote'; -export * from './data-scope-select'; -export * from './scope-config-form'; -export * from './scope-config-select'; +export * from './logs'; diff --git a/config-ui/src/routes/onboard/components/logs.tsx b/config-ui/src/routes/onboard/components/logs.tsx new file mode 100644 index 000000000..438254b70 --- /dev/null +++ b/config-ui/src/routes/onboard/components/logs.tsx @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import styled from 'styled-components'; + +const Wrapper = styled.div` + padding: 10px 20px; + font-size: 12px; + color: #70727f; + background: #f6f6f8; + + .title { + font-weight: 600; + } + + ul { + margin-top: 12px; + } + + li { + display: flex; + margin-top: 6px; + position: relative; + + &:first-child { + margin-top: 0; + } + } + + span.name { + flex: 1; + } + + span.status { + flex: 1; + text-align: right; + } + + span.anticon { + position: absolute; + right: -15px; + } +`; + +interface LogsProps { + style?: React.CSSProperties; + log: { + plugin: string; + scopeName: string; + status: string; + tasks: Array<{ + step: number; + name: string; + status: 'pending' | 'running' | 'success' | 'failed'; + finishedRecords: number; + }>; + }; +} + +export const Logs = ({ style, log: { plugin, scopeName, status, tasks } }: LogsProps) => { + if (!plugin) { + return null; + } + + return ( + <Wrapper style={style}> + <div className="title"> + {plugin}:{scopeName} + </div> + <ul> + {tasks.map((task) => ( + <li> + <span className="name"> + Step {task.step} - {task.name} + </span> + {task.status === 'pending' ? ( + <span className="status">Pending</span> + ) : ( + <span className="status">Records collected: {task.finishedRecords}</span> + )} + {task.status === 'running' && <LoadingOutlined />} + {task.status === 'success' && <CheckCircleOutlined />} + {task.status === 'failed' && <CloseCircleOutlined />} + </li> + ))} + </ul> + </Wrapper> + ); +}; diff --git a/config-ui/src/routes/onboard/context.tsx b/config-ui/src/routes/onboard/context.tsx index 7f472d94f..c7aa00c34 100644 --- a/config-ui/src/routes/onboard/context.tsx +++ b/config-ui/src/routes/onboard/context.tsx @@ -18,17 +18,21 @@ import { createContext } from 'react'; +export type Record = { + plugin: string; + connectionId: ID; + pipelineId: ID; + scopeName: string; +}; + const initialValue: { step: number; - records: Array<{ - plugin: string; - connectionId: ID; - }>; + records: Record[]; done: boolean; projectName?: string; plugin?: string; setStep: (value: number) => void; - setRecords: (value: Array<{ plugin: string; connectionId: ID }>) => void; + setRecords: (value: Record[]) => void; setProjectName: (value: string) => void; setPlugin: (value: string) => void; } = { diff --git a/config-ui/src/routes/onboard/index.tsx b/config-ui/src/routes/onboard/index.tsx index d2b4d1e6e..addbfc451 100644 --- a/config-ui/src/routes/onboard/index.tsx +++ b/config-ui/src/routes/onboard/index.tsx @@ -24,6 +24,7 @@ import API from '@/api'; import { PageLoading } from '@/components'; import { useRefreshData } from '@/hooks'; +import type { Record } from './context'; import { Context } from './context'; import { Step0 } from './step-0'; import { Step1 } from './step-1'; @@ -49,7 +50,7 @@ const steps = [ export const Onboard = () => { const [step, setStep] = useState(0); - const [records, setRecords] = useState<Array<{ plugin: string; connectionId: ID }>>([]); + const [records, setRecords] = useState<Record[]>([]); const [projectName, setProjectName] = useState<string>(); const [plugin, setPlugin] = useState<string>(); diff --git a/config-ui/src/routes/onboard/step-2.tsx b/config-ui/src/routes/onboard/step-2.tsx index be4552463..3de264787 100644 --- a/config-ui/src/routes/onboard/step-2.tsx +++ b/config-ui/src/routes/onboard/step-2.tsx @@ -96,7 +96,7 @@ export const Step2 = () => { ...payload, }); - const newRecords = [...records, { plugin, connectionId: connection.id }]; + const newRecords = [...records, { plugin, connectionId: connection.id, pipelineId: '', scopeName: '' }]; setRecords(newRecords); diff --git a/config-ui/src/routes/onboard/step-3.tsx b/config-ui/src/routes/onboard/step-3.tsx index c66e3f9ab..3fba3cfff 100644 --- a/config-ui/src/routes/onboard/step-3.tsx +++ b/config-ui/src/routes/onboard/step-3.tsx @@ -36,7 +36,7 @@ export const Step3 = () => { const [operating, setOperating] = useState(false); const [scopes, setScopes] = useState<any[]>([]); - const { step, records, done, projectName, plugin, setStep } = useContext(Context); + const { step, records, done, projectName, plugin, setStep, setRecords } = useContext(Context); useEffect(() => { fetch(`/onboard/step-3/${plugin}.md`) @@ -97,17 +97,22 @@ export const Step3 = () => { // 4. trigger this blueprint await API.blueprint.trigger(blueprint.id, { skipCollectors: false, fullSync: false }); + // 5. get current run pipeline + const pipeline = await API.blueprint.pipelines(blueprint.id); + const newRecords = records.map((it) => it.plugin !== plugin ? it : { ...it, - scopeId: getPluginScopeId(plugin, scopes[0].data), + pipelineId: pipeline.pipelines[0].id, scopeName: scopes[0]?.fullName ?? scopes[0].name, }, ); - // 5. update store + setRecords(newRecords); + + // 6. update store await API.store.set('onboard', { step: 4, records: newRecords, diff --git a/config-ui/src/routes/onboard/step-4.tsx b/config-ui/src/routes/onboard/step-4.tsx index e2d51cfed..eb1b92d6c 100644 --- a/config-ui/src/routes/onboard/step-4.tsx +++ b/config-ui/src/routes/onboard/step-4.tsx @@ -16,11 +16,17 @@ * */ -import { SmileFilled } from '@ant-design/icons'; -import { theme } from 'antd'; +import { useState, useContext, useMemo } from 'react'; +import { SmileFilled, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { theme, Progress, Space, Button, Modal } from 'antd'; import styled from 'styled-components'; -import * as S from './styled'; +import API from '@/api'; +import { useAutoRefresh } from '@/hooks'; +import { ConnectionName, ConnectionForm } from '@/plugins'; + +import { Logs } from './components'; +import { Context } from './context'; const Top = styled.div` display: flex; @@ -36,18 +42,213 @@ const Top = styled.div` } `; +const Content = styled.div` + padding: 24px; + background-color: #fff; + box-shadow: 0px 2.4px 4.8px -0.8px rgba(0, 0, 0, 0.1), 0px 1.6px 8px 0px rgba(0, 0, 0, 0.07); + + .top { + margin-bottom: 42px; + text-align: center; + + .info { + margin-bottom: 34px; + font-size: 16px; + font-weight: 600; + } + + .tip { + margin-bottom: 42px; + font-size: 12px; + color: #818388; + } + + .action { + margin-top: 30px; + } + } +`; + +const LogsWrapper = styled.div` + .tip { + font-size: 12px; + font-weight: 600; + color: #70727f; + } + + .detail { + display: flex; + margin-top: 12px; + + & > div { + flex: 1; + } + } +`; + +const getStatus = (data: any) => { + if (!data) { + return 'running'; + } + + switch (data.status) { + case 'TASK_COMPLETED': + return 'success'; + case 'TASK_PARTIAL': + return 'partial'; + case 'TASK_FAILED': + return 'failed'; + case 'TASK_RUNNING': + default: + return 'running'; + } +}; + export const Step4 = () => { + const [open, setOpen] = useState(false); + + const { records, plugin } = useContext(Context); + + const record = useMemo(() => records.find((it) => it.plugin === plugin), [plugin, records]); + + const { data } = useAutoRefresh( + async () => { + const taskRes = await API.pipeline.subTasks(record?.pipelineId as string); + return taskRes.tasks; + }, + [], + { + cancel: (data) => { + return !!(data && ['TASK_COMPLETED', 'TASK_PARTIAL', 'TASK_FAILED'].includes(data.status)); + }, + }, + ); + + const [status, percent, collector, extractor] = useMemo(() => { + const status = getStatus(data); + const percent = (data?.completionRate ?? 0) * 100; + + const collectorTask = (data?.subtasks ?? [])[0] ?? {}; + const extractorTask = (data?.subtasks ?? [])[1] ?? {}; + + const collector = { + plugin: collectorTask.plugin, + scopeName: collectorTask.option?.name, + status: collectorTask.status, + tasks: (collectorTask.subtaskDetails ?? []) + .filter((it: any) => it.is_collector === '1') + .map((it: any) => ({ + step: it.sequence, + name: it.name, + status: it.is_failed === '1' ? 'failed' : !it.began_at ? 'pending' : it.finished_at ? 'success' : 'running', + finishedRecords: it.finished_records, + })), + }; + + const extractor = { + plugin: extractorTask.plugin, + scopeName: extractorTask.option?.name, + status: extractorTask.status, + tasks: (extractorTask.subtaskDetails ?? []) + .filter((it: any) => it.is_collector === '1') + .map((it: any) => ({ + step: it.sequence, + name: it.name, + status: it.is_failed === '1' ? 'failed' : !it.began_at ? 'pending' : it.finished_at ? 'success' : 'running', + finishedRecords: it.finished_records, + })), + }; + + return [status, percent, collector, extractor]; + }, [data]); + const { - token: { green5 }, + token: { green5, orange5, red5 }, } = theme.useToken(); + if (!plugin || !record) { + return null; + } + + const { connectionId, scopeName } = record; + return ( <> <Top> <SmileFilled style={{ fontSize: 36, color: green5 }} /> <span className="text">Congratulations!You have successfully connected to your first repository!</span> </Top> - <S.StepContent style={{ padding: 24 }}></S.StepContent> + <Content> + {status === 'running' && ( + <div className="top"> + <div className="info">syncing up data from {scopeName}...</div> + <div className="tip"> + This may take a few minutes to hours, depending on the size of your data and rate limits of the tool you + choose. Exit + </div> + <Progress type="circle" size={120} percent={percent} /> + </div> + )} + {status === 'success' && ( + <div className="top"> + <div className="info">{scopeName} is successfully collected !</div> + <CheckCircleOutlined style={{ fontSize: 120, color: green5 }} /> + <div className="action"> + <Space direction="vertical"> + <Button type="primary">Check Dashboard</Button> + <Button type="link">finish</Button> + </Space> + </div> + </div> + )} + {status === 'partial' && ( + <div className="top"> + <div className="info">{scopeName} is parted collected!</div> + <CheckCircleOutlined style={{ fontSize: 120, color: orange5 }} /> + <div className="action"> + <Space> + <Button type="primary">Re-collect Data</Button> + <Button type="primary">Check Dashboard</Button> + </Space> + </div> + </div> + )} + {status === 'failed' && ( + <div className="top"> + <div className="info">Something went wrong with the collection process. </div> + <div className="info"> + Please check out the + <Button type="link" onClick={() => setOpen(true)}> + network and token permission + </Button> + and retry data collection + </div> + <CloseCircleOutlined style={{ fontSize: 120, color: red5 }} /> + <div className="action"> + <Space direction="vertical"> + <Button type="primary">Re-collect Data</Button> + </Space> + </div> + </div> + )} + <LogsWrapper> + <div className="tip">Sync progress details</div> + <div className="detail"> + <Logs log={collector} /> + <Logs log={extractor} style={{ marginLeft: 16 }} /> + </div> + </LogsWrapper> + </Content> + <Modal + open={open} + width={820} + centered + title={<ConnectionName plugin={plugin} />} + footer={null} + onCancel={() => setOpen(false)} + > + <ConnectionForm plugin={plugin} connectionId={connectionId} onSuccess={() => setOpen(false)} /> + </Modal> </> ); };
