From 9dca0e8428e755eb54b01366511ef73f7390db9b Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 6 Dec 2024 12:34:38 +0300 Subject: [PATCH] feat: implement info panel --- lib/gui/constants/client-events.ts | 4 +- lib/gui/event-source.ts | 5 ++ lib/static/modules/action-names.ts | 3 +- .../modules/actions/gui-server-connection.ts | 11 ++++ lib/static/modules/actions/types.ts | 4 +- lib/static/modules/default-state.ts | 7 +- .../modules/reducers/gui-server-connection.ts | 21 ++++++ lib/static/modules/reducers/index.js | 4 +- lib/static/new-ui/app/gui.tsx | 16 +++++ .../components/AsidePanel/index.module.css | 14 ++++ .../new-ui/components/AsidePanel/index.tsx | 19 ++++++ .../InfoPanel/DataSourceItem.module.css | 66 +++++++++++++++++++ .../components/InfoPanel/DataSourceItem.tsx | 34 ++++++++++ .../components/InfoPanel/index.module.css | 55 ++++++++++++++++ .../new-ui/components/InfoPanel/index.tsx | 66 +++++++++++++++++++ .../new-ui/components/MainLayout/Footer.tsx | 25 +++++-- .../new-ui/components/MainLayout/index.tsx | 8 ++- .../components/PanelSection/index.module.css | 5 ++ .../new-ui/components/PanelSection/index.tsx | 17 +++++ .../components/SettingsPanel/index.module.css | 31 +-------- .../new-ui/components/SettingsPanel/index.tsx | 38 ++++------- lib/static/new-ui/types/store.ts | 7 ++ lib/static/styles.css | 2 + 23 files changed, 397 insertions(+), 65 deletions(-) create mode 100644 lib/static/modules/actions/gui-server-connection.ts create mode 100644 lib/static/modules/reducers/gui-server-connection.ts create mode 100644 lib/static/new-ui/components/AsidePanel/index.module.css create mode 100644 lib/static/new-ui/components/AsidePanel/index.tsx create mode 100644 lib/static/new-ui/components/InfoPanel/DataSourceItem.module.css create mode 100644 lib/static/new-ui/components/InfoPanel/DataSourceItem.tsx create mode 100644 lib/static/new-ui/components/InfoPanel/index.module.css create mode 100644 lib/static/new-ui/components/InfoPanel/index.tsx create mode 100644 lib/static/new-ui/components/PanelSection/index.module.css create mode 100644 lib/static/new-ui/components/PanelSection/index.tsx diff --git a/lib/gui/constants/client-events.ts b/lib/gui/constants/client-events.ts index e1216cda1..fcaad8b93 100644 --- a/lib/gui/constants/client-events.ts +++ b/lib/gui/constants/client-events.ts @@ -9,7 +9,9 @@ export const ClientEvents = { RETRY: 'retry', ERROR: 'err', - END: 'end' + END: 'end', + + CONNECTED: 'connected' } as const; export type ClientEvents = typeof ClientEvents; diff --git a/lib/gui/event-source.ts b/lib/gui/event-source.ts index 7d033fb4a..5bf5ca3dc 100644 --- a/lib/gui/event-source.ts +++ b/lib/gui/event-source.ts @@ -1,5 +1,6 @@ import {Response} from 'express'; import stringify from 'json-stringify-safe'; +import {ClientEvents} from './constants'; export class EventSource { private _connections: Response[]; @@ -9,6 +10,10 @@ export class EventSource { addConnection(connection: Response): void { this._connections.push(connection); + + connection.write('event: ' + ClientEvents.CONNECTED + '\n'); + connection.write('data: 1\n'); + connection.write('\n\n'); } emit(event: string, data?: unknown): void { diff --git a/lib/static/modules/action-names.ts b/lib/static/modules/action-names.ts index be76a25ea..4cea14b0e 100644 --- a/lib/static/modules/action-names.ts +++ b/lib/static/modules/action-names.ts @@ -75,5 +75,6 @@ export default { UPDATE_LOADING_TITLE: 'UPDATE_LOADING_TITLE', UPDATE_LOADING_IS_IN_PROGRESS: 'UPDATE_LOADING_IS_IN_PROGRESS', SELECT_ALL: 'SELECT_ALL', - DESELECT_ALL: 'DESELECT_ALL' + DESELECT_ALL: 'DESELECT_ALL', + SET_GUI_SERVER_CONNECTION_STATUS: 'SET_GUI_SERVER_CONNECTION_STATUS' } as const; diff --git a/lib/static/modules/actions/gui-server-connection.ts b/lib/static/modules/actions/gui-server-connection.ts new file mode 100644 index 000000000..8e35938da --- /dev/null +++ b/lib/static/modules/actions/gui-server-connection.ts @@ -0,0 +1,11 @@ +import {Action} from '@/static/modules/actions/types'; +import actionNames from '@/static/modules/action-names'; + +type SetGuiServerConnectionStatusAction = Action; +export const setGuiServerConnectionStatus = (payload: SetGuiServerConnectionStatusAction['payload']): SetGuiServerConnectionStatusAction => + ({type: actionNames.SET_GUI_SERVER_CONNECTION_STATUS, payload}); + +export type GuiServerConnectionAction = SetGuiServerConnectionStatusAction; diff --git a/lib/static/modules/actions/types.ts b/lib/static/modules/actions/types.ts index d90ad9e66..0f04f0cb6 100644 --- a/lib/static/modules/actions/types.ts +++ b/lib/static/modules/actions/types.ts @@ -7,6 +7,7 @@ import {ThunkAction} from 'redux-thunk'; import {State} from '@/static/new-ui/types/store'; import {LifecycleAction} from '@/static/modules/actions/lifecycle'; import {SuitesPageAction} from '@/static/modules/actions/suites-page'; +import {GuiServerConnectionAction} from '@/static/modules/actions/gui-server-connection'; export type {Dispatch} from 'redux'; @@ -22,4 +23,5 @@ export type AppThunk> = ThunkAction { + switch (action.type) { + case actionNames.SET_GUI_SERVER_CONNECTION_STATUS: { + return applyStateUpdate(state, { + app: { + guiServerConnection: { + isConnected: action.payload.isConnected, + wasDisconnected: action.payload.wasDisconnected + } + } + }); + } + default: + return state; + } +}; diff --git a/lib/static/modules/reducers/index.js b/lib/static/modules/reducers/index.js index dd6132265..cdd17a939 100644 --- a/lib/static/modules/reducers/index.js +++ b/lib/static/modules/reducers/index.js @@ -30,6 +30,7 @@ import suitesPage from './suites-page'; import visualChecksPage from './visual-checks-page'; import isInitialized from './is-initialized'; import newUiGroupedTests from './new-ui-grouped-tests'; +import guiServerConnection from './gui-server-connection'; // The order of specifying reducers is important. // At the top specify reducers that does not depend on other state fields. @@ -64,5 +65,6 @@ export default reduceReducers( progressBar, suitesPage, visualChecksPage, - isInitialized + isInitialized, + guiServerConnection ); diff --git a/lib/static/new-ui/app/gui.tsx b/lib/static/new-ui/app/gui.tsx index 1cd08ef50..791fc8f09 100644 --- a/lib/static/new-ui/app/gui.tsx +++ b/lib/static/new-ui/app/gui.tsx @@ -5,6 +5,8 @@ import {ClientEvents} from '@/gui/constants'; import {App} from './App'; import store from '../../modules/store'; import {finGuiReport, thunkInitGuiReport, suiteBegin, testBegin, testResult, testsEnd} from '../../modules/actions'; +import {setGuiServerConnectionStatus} from '@/static/modules/actions/gui-server-connection'; +import actionNames from '@/static/modules/action-names'; const rootEl = document.getElementById('app') as HTMLDivElement; const root = createRoot(rootEl); @@ -13,6 +15,20 @@ function Gui(): ReactNode { const subscribeToEvents = (): void => { const eventSource = new EventSource('/events'); + eventSource.addEventListener(ClientEvents.CONNECTED, (): void => { + store.dispatch({type: actionNames.UPDATE_LOADING_VISIBILITY, payload: false}); + + store.dispatch(setGuiServerConnectionStatus({isConnected: true})); + }); + + eventSource.onerror = (): void => { + store.dispatch({type: actionNames.UPDATE_LOADING_IS_IN_PROGRESS, payload: true}); + store.dispatch({type: actionNames.UPDATE_LOADING_TITLE, payload: 'Lost connection to Testplane UI server. Trying to reconnect'}); + store.dispatch({type: actionNames.UPDATE_LOADING_VISIBILITY, payload: true}); + + store.dispatch(setGuiServerConnectionStatus({isConnected: false, wasDisconnected: true})); + }; + eventSource.addEventListener(ClientEvents.BEGIN_SUITE, (e) => { const data = JSON.parse(e.data); store.dispatch(suiteBegin(data)); diff --git a/lib/static/new-ui/components/AsidePanel/index.module.css b/lib/static/new-ui/components/AsidePanel/index.module.css new file mode 100644 index 000000000..4724ec62b --- /dev/null +++ b/lib/static/new-ui/components/AsidePanel/index.module.css @@ -0,0 +1,14 @@ +.container { + background-color: #fff; + padding: 20px; + width: 440px; + height: 100%; + overflow-x: scroll; + + --g-text-body-font-weight: 450; + --g-text-body-short-font-size: 15px; +} + +.divider { + margin: 12px 0 20px 0; +} diff --git a/lib/static/new-ui/components/AsidePanel/index.tsx b/lib/static/new-ui/components/AsidePanel/index.tsx new file mode 100644 index 000000000..59cfcc21d --- /dev/null +++ b/lib/static/new-ui/components/AsidePanel/index.tsx @@ -0,0 +1,19 @@ +import {Divider} from '@gravity-ui/uikit'; +import classNames from 'classnames'; +import React, {ReactNode} from 'react'; + +import styles from './index.module.css'; + +interface AsidePanelProps { + title: string; + children?: ReactNode; + className?: string; +} + +export function AsidePanel(props: AsidePanelProps): ReactNode { + return
+

{props.title}

+ + {props.children} +
; +} diff --git a/lib/static/new-ui/components/InfoPanel/DataSourceItem.module.css b/lib/static/new-ui/components/InfoPanel/DataSourceItem.module.css new file mode 100644 index 000000000..b16f4e777 --- /dev/null +++ b/lib/static/new-ui/components/InfoPanel/DataSourceItem.module.css @@ -0,0 +1,66 @@ +.data-source-item { + display: flex; + justify-content: space-between; + padding: 12px 4px; + gap: 28px; +} + +.data-source-title-container { + display: flex; + gap: 8px; + overflow: hidden; +} + +.data-source-icon { + flex-shrink: 0; + color: var(--g-color-private-black-450-solid); +} + +.data-source-status { + font-family: monospace; + display: flex; + align-items: center; + gap: 8px; +} + +.data-source-status-circle { + width: 6px; + height: 6px; + border-radius: 100vh; +} + +.data-source-status-circle-offline { + box-shadow: 0 0 0 4px var(--g-color-private-red-50); + background-color: var(--g-color-private-red-600-solid); +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 var(--pulse-color-from); + } + + 100% { + box-shadow: 0 0 0 4px var(--pulse-color-to); + } +} + +.data-source-status-circle-online { + --pulse-color-from: var(--g-color-private-green-500); + --pulse-color-to: rgba(255 255 255 / 0); + animation: pulse 2s ease infinite; + background-color: var(--g-color-private-green-550-solid); +} + +.title { + white-space: nowrap; + display: inline-block; +} + +.ellipsis { + display: inline-block; + vertical-align: bottom; + white-space: nowrap; + max-width: var(--max-width); + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/lib/static/new-ui/components/InfoPanel/DataSourceItem.tsx b/lib/static/new-ui/components/InfoPanel/DataSourceItem.tsx new file mode 100644 index 000000000..348b03852 --- /dev/null +++ b/lib/static/new-ui/components/InfoPanel/DataSourceItem.tsx @@ -0,0 +1,34 @@ +import {Icon, IconProps} from '@gravity-ui/uikit'; +import classNames from 'classnames'; +import React, {ReactNode} from 'react'; + +import styles from './DataSourceItem.module.css'; + +interface DataSourceItemProps { + icon: IconProps['data']; + title: string; + success: boolean; + statusCode?: number | string; + url?: string; +} + +export function DataSourceItem(props: DataSourceItemProps): ReactNode { + const urlTitle = props.url?.replace(/^https?:\/\//, ''); + + return
+
+ + {props.url && urlTitle ? + // Here we want to place ellipsis in the middle of the long url, e.g. example.com/lo...ng/1234.sqlite + + {urlTitle.slice(0, -24)} + {urlTitle.slice(-24)} + : + {props.title}} +
+
{props.success ? 'OK' : (props.statusCode && typeof props.statusCode === 'number' ? `E${props.statusCode}` : 'ERR')} +
+
+
; +} diff --git a/lib/static/new-ui/components/InfoPanel/index.module.css b/lib/static/new-ui/components/InfoPanel/index.module.css new file mode 100644 index 000000000..17dca43c4 --- /dev/null +++ b/lib/static/new-ui/components/InfoPanel/index.module.css @@ -0,0 +1,55 @@ +.divider { + margin: 20px 0; +} + +.info-panel a:link, .info-panel a:visited { + color: var(--color-link); +} + +.info-panel a:hover { + color: var(--color-link-hover); +} + +.extra-items-list { + padding-left: 24px; + list-style: none; +} + +.extra-items-list li { + position: relative; + margin-bottom: 12px; +} + +.extra-items-list li::before { + content: ''; + height: 4px; + width: 4px; + border-radius: 100vh; + background-color: #000; + position: absolute; + top: 50%; + transform: translateY(-50%); + left: -10px; +} + +.extra-items-list a { + display: inline-block; +} + +.extra-items-list a::first-letter { + text-transform: uppercase; +} + +.data-source-heading { + display: flex; + justify-content: space-between; + font-weight: 450; + border-bottom: 1px solid var(--g-divider-color); + padding-bottom: 8px; +} + +.data-source-list { + display: flex; + flex-direction: column; +} + diff --git a/lib/static/new-ui/components/InfoPanel/index.tsx b/lib/static/new-ui/components/InfoPanel/index.tsx new file mode 100644 index 000000000..614f88391 --- /dev/null +++ b/lib/static/new-ui/components/InfoPanel/index.tsx @@ -0,0 +1,66 @@ +import {Server, Database} from '@gravity-ui/icons'; +import {Divider} from '@gravity-ui/uikit'; +import {isEmpty} from 'lodash'; +import React, {ReactNode} from 'react'; +import {useSelector} from 'react-redux'; + +import {AsidePanel} from '@/static/new-ui/components/AsidePanel'; +import {DataSourceItem} from '@/static/new-ui/components/InfoPanel/DataSourceItem'; +import {PanelSection} from '@/static/new-ui/components/PanelSection'; +import styles from './index.module.css'; +import {version} from '../../../../../package.json'; + +export function InfoPanel(): ReactNode { + const isGui = useSelector(state => state.gui); + const extraItems = Object.entries(useSelector(state => state.apiValues.extraItems)); + + const sections: ReactNode[] = []; + + if (extraItems.length > 0) { + sections.push( +
    + {extraItems.map((item, index) => (
  • {item[0]}
  • ))} +
+
); + } + + sections.push(To get the most out of Testplane UI, try to keep it updated to the latest version. Check out fresh releases on GitHub.} + />); + + const timestamp = useSelector(state => state.timestamp); + const lang = isEmpty(navigator.languages) ? navigator.language : navigator.languages[0]; + const date = new Date(timestamp).toLocaleString(lang); + if (!isGui) { + sections.push(); + } + + const isConnectedToGuiServer = useSelector(state => state.app.guiServerConnection.isConnected); + const dbDetails = useSelector(state => state.fetchDbDetails); + sections.push( +
+
+ SourceStatus +
+
+ {isGui && } + {!isGui && dbDetails.map(({url, success, status}, index) => )} +
+
+
); + + return + {sections.slice(0, -1).map((section, index) => + {section} + + )} + {sections[sections.length - 1]} + ; +} diff --git a/lib/static/new-ui/components/MainLayout/Footer.tsx b/lib/static/new-ui/components/MainLayout/Footer.tsx index 0a9f9da63..3d44ade68 100644 --- a/lib/static/new-ui/components/MainLayout/Footer.tsx +++ b/lib/static/new-ui/components/MainLayout/Footer.tsx @@ -1,4 +1,4 @@ -import {Gear} from '@gravity-ui/icons'; +import {Gear, CircleInfo} from '@gravity-ui/icons'; import {FooterItem, MenuItem as GravityMenuItem} from '@gravity-ui/navigation'; import {Icon} from '@gravity-ui/uikit'; import classNames from 'classnames'; @@ -45,20 +45,35 @@ export function Footer(props: FooterProps): ReactNode { } }, [props.visiblePanel]); - const isCurrent = props.visiblePanel === PanelId.Settings; + const isInfoCurrent = props.visiblePanel === PanelId.Info; + const isSettingsCurrent = props.visiblePanel === PanelId.Settings; return <> setIsHintVisible(false)} /> + makeItem({ + ...params, + icon: + }) + }} /> makeItem({ ...params, icon: }) diff --git a/lib/static/new-ui/components/MainLayout/index.tsx b/lib/static/new-ui/components/MainLayout/index.tsx index 014b7323e..a0ff64556 100644 --- a/lib/static/new-ui/components/MainLayout/index.tsx +++ b/lib/static/new-ui/components/MainLayout/index.tsx @@ -10,9 +10,11 @@ import TestplaneIcon from '../../../icons/testplane-mono.svg'; import styles from './index.module.css'; import {Footer} from './Footer'; import {EmptyReportCard} from '@/static/new-ui/components/Card/EmptyReportCard'; +import {InfoPanel} from '@/static/new-ui/components/InfoPanel'; export enum PanelId { Settings = 'settings', + Info = 'info', } interface MenuItem { @@ -45,7 +47,7 @@ export function MainLayout(props: MainLayoutProps): ReactNode { const [visiblePanel, setVisiblePanel] = useState(null); const onFooterItemClick = (item: GravityMenuItem): void => { - visiblePanel ? setVisiblePanel(null) : setVisiblePanel(item.id as PanelId); + visiblePanel === item.id ? setVisiblePanel(null) : setVisiblePanel(item.id as PanelId); }; return